From 8ed7c083c44ea546e742ec2392902d2262dcd5a3 Mon Sep 17 00:00:00 2001 From: Sebastian Mayer Date: Thu, 28 May 2026 01:09:53 +0200 Subject: [PATCH] Refactor: God-Router + Frontend-Lib in Domain-Module aufgeteilt Router-Split (packages/api/src/trpc/routers/health/): - health.ts (2061 Zeilen Monolith) entfernt und in 6 Sub-Router aufgeteilt: - dashboard.ts (overview, profile, goals, point settings) - foods.ts (foods, nutrition, OpenFoodFacts, food preferences) - recipes.ts (recipes, submissions, reviews, activity feed) - stores.ts (stores, change-requests, shopping lists) - tracking.ts (meals, drinks, weight, mealPlans, activities, leaderboard) - community.ts (club, wearables, coach, stats) - index.ts mergt alle Sub-Router via mergeRouters, sodass Frontend-Aufrufe (trpc.health.listFoods etc.) unveraendert weiterfunktionieren. - t.mergeRouters in init.ts exportiert. Frontend-Lib-Split (apps/admin/lib/hellth/): - hellth-data.ts (1149 Zeilen Monolith) in 5 Domain-Module aufgeteilt: - types.ts (alle TypeScript-Types) - constants.ts (Seeds, Defaults, todayKey, uid) - nutrition.ts (calcRecipe, classifyRecipe, parseNutritionLabel, ...) - storage.ts (load/save HellthData, ClubData, hasCompletedHealthProfile) - stats.ts (Budget, Stats, Coach, Shopping, Weight-Projection) - hellth-data.ts wird zum reinen Re-Export-Index, damit bestehende Imports `from "@/lib/hellth-data"` unveraendert weiterfunktionieren. Funktional identisch zum Original - reine Code-Reorganisation. Verifiziert: 28 API-Tests + 10 Admin-Tests gruen, Typecheck (3 Packages) gruen, Admin-Build (26 Pages) gruen. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin/lib/hellth-data.ts | 1158 +-------- apps/admin/lib/hellth/constants.ts | 184 ++ apps/admin/lib/hellth/nutrition.ts | 146 ++ apps/admin/lib/hellth/stats.ts | 390 ++++ apps/admin/lib/hellth/storage.ts | 161 ++ apps/admin/lib/hellth/types.ts | 319 +++ packages/api/src/trpc/init.ts | 1 + packages/api/src/trpc/routers/health.ts | 2062 ----------------- .../api/src/trpc/routers/health/community.ts | 448 ++++ .../api/src/trpc/routers/health/dashboard.ts | 169 ++ packages/api/src/trpc/routers/health/foods.ts | 247 ++ packages/api/src/trpc/routers/health/index.ts | 18 + .../api/src/trpc/routers/health/recipes.ts | 423 ++++ .../api/src/trpc/routers/health/stores.ts | 375 +++ .../api/src/trpc/routers/health/tracking.ts | 462 ++++ 15 files changed, 3352 insertions(+), 3211 deletions(-) create mode 100644 apps/admin/lib/hellth/constants.ts create mode 100644 apps/admin/lib/hellth/nutrition.ts create mode 100644 apps/admin/lib/hellth/stats.ts create mode 100644 apps/admin/lib/hellth/storage.ts create mode 100644 apps/admin/lib/hellth/types.ts delete mode 100644 packages/api/src/trpc/routers/health.ts create mode 100644 packages/api/src/trpc/routers/health/community.ts create mode 100644 packages/api/src/trpc/routers/health/dashboard.ts create mode 100644 packages/api/src/trpc/routers/health/foods.ts create mode 100644 packages/api/src/trpc/routers/health/index.ts create mode 100644 packages/api/src/trpc/routers/health/recipes.ts create mode 100644 packages/api/src/trpc/routers/health/stores.ts create mode 100644 packages/api/src/trpc/routers/health/tracking.ts diff --git a/apps/admin/lib/hellth-data.ts b/apps/admin/lib/hellth-data.ts index cc994c4..1bb2209 100644 --- a/apps/admin/lib/hellth-data.ts +++ b/apps/admin/lib/hellth-data.ts @@ -1,1149 +1,9 @@ -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]), - }; -} - +// Re-Export-Index fuer die hellth-Lib. Aus historischen Gruenden importieren die +// Pages aus "@/lib/hellth-data"; dieser Stub leitet alles an die Domain-Module +// unter lib/hellth/ weiter. + +export * from "./hellth/types"; +export * from "./hellth/constants"; +export * from "./hellth/nutrition"; +export * from "./hellth/storage"; +export * from "./hellth/stats"; diff --git a/apps/admin/lib/hellth/constants.ts b/apps/admin/lib/hellth/constants.ts new file mode 100644 index 0000000..5eb90d9 --- /dev/null +++ b/apps/admin/lib/hellth/constants.ts @@ -0,0 +1,184 @@ +import type { + ActivityTemplate, + CatalogFood, + ClubActivityCategory, + ClubProfile, + GermanState, + HellthGoals, + PlannedMeal, + Recipe, + ReminderSettings, + Store, + StoreDepartment, +} from "./types"; + +export const HELLTH_DATA_KEY = "hellth-hub-data-v1"; +export const HELLTH_CLUB_KEY = "hellth-hub-club-v1"; + +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 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 }, +]; diff --git a/apps/admin/lib/hellth/nutrition.ts b/apps/admin/lib/hellth/nutrition.ts new file mode 100644 index 0000000..ed896c7 --- /dev/null +++ b/apps/admin/lib/hellth/nutrition.ts @@ -0,0 +1,146 @@ +import type { + CatalogFood, + PortionUnit, + Recipe, + RecipeIngredient, + RecipeRating, +} from "./types"; + +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]), + }; +} + +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, + }; +} diff --git a/apps/admin/lib/hellth/stats.ts b/apps/admin/lib/hellth/stats.ts new file mode 100644 index 0000000..5c7ae96 --- /dev/null +++ b/apps/admin/lib/hellth/stats.ts @@ -0,0 +1,390 @@ +import { todayKey } from "./constants"; +import { calcRecipe } from "./nutrition"; +import type { + CatalogFood, + CoachRecommendation, + HealthProfile, + HealthProjection, + HealthStatsDay, + HealthStatsSummary, + HellthData, + HellthDayStateSnapshot, + HellthGoals, + ShoppingItem, + Store, + WeeklyBudgetSummary, +} from "./types"; +import { uid } from "./constants"; + +export function startOfWeek(dateKey: string) { + const date = new Date(`${dateKey}T12:00:00`); + const day = date.getDay() === 0 ? 7 : date.getDay(); + date.setDate(date.getDate() - day + 1); + return date.toISOString().slice(0, 10); +} + +export function weekDates(dateKey: string) { + const monday = new Date(`${startOfWeek(dateKey)}T12:00:00`); + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(monday); + date.setDate(monday.getDate() + index); + return date.toISOString().slice(0, 10); + }); +} + +export function addDateDays(dateKey: string, days: number) { + const date = new Date(`${dateKey}T12:00:00`); + date.setDate(date.getDate() + days); + return date.toISOString().slice(0, 10); +} + +export function buildWeeklyBudgetSummary(data: HellthData, dateKey = todayKey()): WeeklyBudgetSummary { + const dates = weekDates(dateKey); + const dailyGoal = data.goals.calories; + const planByDate = new Map(data.weeklyCaloriePlans.map((plan) => [plan.date, plan])); + const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe])); + + const days = dates.map((date) => { + const plannedCalories = planByDate.get(date)?.plannedCalories ?? dailyGoal; + const plannedMeals = data.plannedMeals.filter((meal) => meal.date === date); + const eatenCalories = plannedMeals.reduce((sum, meal) => { + const recipe = meal.recipeId ? recipeById.get(meal.recipeId) : undefined; + if (!recipe) return sum; + return sum + calcRecipe(recipe, data.foods, recipe.portions).kcal * meal.portions; + }, 0); + const activityCalories = data.activityLogs + .filter((log) => log.date === date) + .reduce((sum, log) => sum + (log.calories ?? 0), 0); + return { + date, + plannedCalories, + eatenCalories, + activityCalories, + balance: plannedCalories + activityCalories - eatenCalories, + note: planByDate.get(date)?.note, + }; + }); + + const plannedBudget = days.reduce((sum, day) => sum + day.plannedCalories, 0); + const eatenCalories = days.reduce((sum, day) => sum + day.eatenCalories, 0); + const activityCalories = days.reduce((sum, day) => sum + day.activityCalories, 0); + const availableCalories = plannedBudget + activityCalories - eatenCalories; + + return { + weekStart: dates[0]!, + dailyGoal, + baseBudget: dailyGoal * 7, + plannedBudget, + eatenCalories, + activityCalories, + availableCalories, + balance: plannedBudget + activityCalories - eatenCalories, + days, + }; +} + +function plannedCaloriesForDate(data: HellthData, date: string) { + const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe])); + return data.plannedMeals + .filter((meal) => meal.date === date) + .reduce((sum, meal) => { + const recipe = meal.recipeId ? recipeById.get(meal.recipeId) : undefined; + if (!recipe) return sum; + return sum + calcRecipe(recipe, data.foods, recipe.portions).kcal * meal.portions; + }, 0); +} + +function latestStreak(days: T[], predicate: (day: T) => boolean) { + let streak = 0; + for (const day of [...days].reverse()) { + if (!predicate(day)) break; + streak += 1; + } + return streak; +} + +function buildWeightProjection( + data: HellthData, + days: HealthStatsDay[], + referenceDate: string, +): HealthProjection | undefined { + const entries = days + .filter((day) => day.weightKg !== undefined) + .map((day) => ({ date: day.date, weightKg: day.weightKg! })); + if (entries.length < 3) return undefined; + + const first = entries[0]!; + const last = entries[entries.length - 1]!; + const elapsedDays = Math.max( + 1, + Math.round((new Date(`${last.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000), + ); + if (elapsedDays < 7) return undefined; + + const trendKgPerWeek = Math.round(((last.weightKg - first.weightKg) / elapsedDays) * 7 * 10) / 10; + const targetWeight = data.healthProfile?.targetWeightKg ?? data.goals.weightKg; + const wantsLowerWeight = targetWeight < last.weightKg; + const movingTowardTarget = wantsLowerWeight ? trendKgPerWeek < -0.05 : trendKgPerWeek > 0.05; + + if (!movingTowardTarget) { + return { + kind: "weight", + trendKgPerWeek, + confidence: "low", + message: "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.", + }; + } + + const kgPerDay = Math.abs(trendKgPerWeek / 7); + const remainingKg = Math.abs(last.weightKg - targetWeight); + const estimatedDays = Math.ceil(remainingKg / Math.max(0.01, kgPerDay)); + const targetDate = addDateDays(referenceDate, estimatedDays); + + return { + kind: "weight", + trendKgPerWeek, + targetDate, + confidence: elapsedDays >= 14 && entries.length >= 5 ? "medium" : "low", + message: `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`, + }; +} + +export function buildHealthStatsSummary( + data: HellthData, + dayState: HellthDayStateSnapshot = {}, + referenceDate = todayKey(), +): HealthStatsSummary { + const days = Array.from({ length: 14 }, (_, index) => addDateDays(referenceDate, index - 13)).map((date) => { + const snapshot = dayState.days?.[date]; + const loggedCalories = snapshot?.foods?.reduce((sum, food) => sum + (Number(food.calories) || 0), 0) ?? 0; + const plannedCalories = plannedCaloriesForDate(data, date); + const activityCalories = data.activityLogs + .filter((log) => log.date === date) + .reduce((sum, log) => sum + (log.calories ?? 0), 0); + + return { + date, + calories: loggedCalories || (date >= referenceDate ? plannedCalories : 0), + plannedCalories, + activityCalories, + protein: snapshot?.foods?.reduce((sum, food) => sum + (Number(food.protein) || 0), 0) ?? 0, + drinkMl: snapshot?.drinks?.reduce((sum, drink) => sum + (Number(drink.amountMl) || 0), 0) ?? 0, + weightKg: snapshot?.weightKg, + }; + }); + + const relevantDays = days.filter((day) => day.date <= referenceDate); + const divisor = Math.max(1, relevantDays.length); + const averages = { + calories: Math.round(relevantDays.reduce((sum, day) => sum + day.calories, 0) / divisor), + protein: Math.round(relevantDays.reduce((sum, day) => sum + day.protein, 0) / divisor), + drinkMl: Math.round(relevantDays.reduce((sum, day) => sum + day.drinkMl, 0) / divisor), + activityCalories: Math.round(relevantDays.reduce((sum, day) => sum + day.activityCalories, 0) / divisor), + }; + + const streaks = { + waterDays: latestStreak(relevantDays, (day) => day.drinkMl >= data.goals.drinkMl), + proteinDays: latestStreak(relevantDays, (day) => day.protein >= data.goals.protein * 0.8), + activityDays: latestStreak(relevantDays, (day) => day.activityCalories > 0), + }; + + const wins = [ + streaks.waterDays > 0 ? `${streaks.waterDays} Tag(e) Trinkziel in Folge` : undefined, + streaks.proteinDays > 0 ? `${streaks.proteinDays} Tag(e) Protein gut getroffen` : undefined, + streaks.activityDays > 0 ? `${streaks.activityDays} Tag(e) mit Bewegung in Folge` : undefined, + averages.activityCalories > 0 ? `Ø ${averages.activityCalories} kcal Sportbonus pro Tag` : undefined, + ].filter(Boolean) as string[]; + + return { + days, + week: buildWeeklyBudgetSummary(data, referenceDate), + averages, + streaks, + wins: wins.length ? wins : ["Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser."], + projection: buildWeightProjection(data, days, referenceDate), + }; +} + +export function redistributeWeeklyCalories( + data: HellthData, + targetDate: string, + extraCalories: number, + referenceDate = todayKey(), +) { + const dates = weekDates(referenceDate); + const dailyGoal = data.goals.calories; + const target = targetDate || referenceDate; + const otherDates = dates.filter((date) => date !== target); + const savingPerOtherDay = otherDates.length ? Math.ceil(extraCalories / otherDates.length) : 0; + return dates.map((date) => ({ + date, + plannedCalories: date === target ? dailyGoal + extraCalories : Math.max(1200, dailyGoal - savingPerOtherDay), + note: date === target ? "bewusst höher geplant" : extraCalories > 0 ? "Ausgleichstag" : undefined, + })); +} + +export function buildGoalsFromProfile(profile: HealthProfile): HellthGoals { + const weight = Math.max(45, profile.currentWeightKg || 80); + const height = Math.max(130, profile.heightCm || 175); + const age = Math.max(18, profile.age || 40); + const activityFactor = + profile.activityLevel === "active" ? 1.55 : profile.activityLevel === "moderate" ? 1.35 : 1.2; + + // Neutral estimate without sex-specific assumptions. Good enough for an onboarding target, + // later refined by real weight trend and adherence. + const bmr = 10 * weight + 6.25 * height - 5 * age - 80; + const maintenance = Math.round(bmr * activityFactor); + const deficit = + profile.goal === "lose_weight" + ? profile.pace === "ambitious" + ? 700 + : profile.pace === "normal" + ? 500 + : 300 + : 0; + const floor = weight > 90 ? 1600 : 1400; + const calories = Math.max(floor, maintenance - deficit); + const protein = Math.round(Math.min(220, Math.max(90, weight * (profile.goal === "lose_weight" ? 1.6 : 1.3)))); + const fat = Math.round((calories * 0.3) / 9); + const carbs = Math.round((calories - protein * 4 - fat * 9) / 4); + + return { + drinkMl: Math.round(Math.min(3500, Math.max(1800, weight * 35)) / 100) * 100, + calories, + carbs: Math.max(80, carbs), + fat, + protein, + weightKg: profile.targetWeightKg || profile.currentWeightKg, + fastingHours: profile.mealsPerDay <= 3 ? 12 : 10, + }; +} + +export function buildCoachRecommendations(profile: HealthProfile, goals: HellthGoals): CoachRecommendation[] { + const recommendations: CoachRecommendation[] = [ + { + id: "calorie-deficit", + title: profile.goal === "lose_weight" ? "Moderates Defizit halten" : "Stabile Routine aufbauen", + body: + profile.goal === "lose_weight" + ? `Starte mit ca. ${goals.calories} kcal pro Tag. Prüfe nach 2 Wochen den Gewichtstrend statt einzelne Tageswerte zu bewerten.` + : `Starte mit ca. ${goals.calories} kcal pro Tag und bewerte Energie, Hunger und Gewichtstrend wöchentlich.`, + priority: "high", + }, + { + id: "protein", + title: "Protein als Sattmacher planen", + body: `Plane pro Tag etwa ${goals.protein} g Protein ein. Praktisch: jede Hauptmahlzeit bekommt eine klare Proteinquelle.`, + priority: "high", + }, + { + id: "water", + title: "Trinken sichtbar machen", + body: `Dein Trinkziel liegt bei ${goals.drinkMl} ml. Starte mit 500 ml bis Mittag und nutze die Wasserkarte als einfache Erinnerung.`, + priority: "medium", + }, + ]; + + if (profile.biggestChallenge.trim()) { + recommendations.push({ + id: "challenge", + title: "Haupt-Hürde zuerst entschärfen", + body: `Du hast "${profile.biggestChallenge.trim()}" als Hürde genannt. Plane dafür eine konkrete Standardlösung, z. B. ein schnelles Notfallgericht oder vorbereitete Snacks.`, + priority: "high", + }); + } + + if (profile.cookingTime === "low") { + recommendations.push({ + id: "simple-meals", + title: "Kurze Rezepte bevorzugen", + body: "Nutze Rezepte unter 20 Minuten und wiederhole gute Mahlzeiten bewusst. Abnehmen wird leichter, wenn nicht jede Mahlzeit neu entschieden werden muss.", + priority: "medium", + }); + } + + if (profile.budgetFocus) { + recommendations.push({ + id: "budget", + title: "Budgetfreundliche Basics", + body: "Haferflocken, Kartoffeln, Reis, Eier, Quark, TK-Gemüse und Hülsenfrüchte eignen sich gut für planbare, günstige Mahlzeiten.", + priority: "low", + }); + } + + if (profile.medicalNote) { + recommendations.unshift({ + id: "medical", + title: "Gesundheitliche Situation beachten", + body: "Bei Diagnosen, Medikamenten, Schwangerschaft oder Essstörungs-Vorgeschichte bitte Ziele mit Arzt oder Ernährungsfachkraft abstimmen. Hellth Hub ersetzt keine medizinische Beratung.", + priority: "high", + }); + } + + return recommendations; +} + +export function isFoodAvailableInStore(food: CatalogFood | undefined, storeId: string) { + if (!food) return true; + if (food.availableEverywhere) return true; + return Boolean(food.storeIds?.includes(storeId)); +} + +export function departmentForFood(food: CatalogFood | undefined, store: Store) { + const explicitId = food?.storeDepartmentIds?.[store.id]?.[0]; + if (explicitId) { + const explicit = store.departments.find((department) => department.id === explicitId); + if (explicit) return explicit; + } + + const category = food?.category.toLowerCase() ?? ""; + const match = store.departments.find((department) => { + const name = department.name.toLowerCase(); + return ( + (category.includes("obst") && name.includes("obst")) || + (category.includes("gemüse") && name.includes("gemüse")) || + (category.includes("brot") && name.includes("brot")) || + (category.includes("getreide") && (name.includes("cerealien") || name.includes("trocken"))) || + (category.includes("milch") && name.includes("milch")) || + (category.includes("protein") && (name.includes("protein") || name.includes("fleisch"))) || + (category.includes("öl") && (name.includes("trocken") || name.includes("sonst"))) + ); + }); + + return match ?? store.departments.find((department) => department.name.toLowerCase().includes("sonst")) ?? store.departments.at(-1); +} + +export function buildShoppingItemsFromPlan(data: HellthData, dates?: string[]) { + const dateSet = dates?.length ? new Set(dates) : null; + const foodById = new Map(data.foods.map((food) => [food.id, food])); + const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe])); + const previousByFoodId = new Map(data.shoppingItems.filter((item) => item.foodId).map((item) => [item.foodId!, item])); + const previousManualItems = data.shoppingItems.filter((item) => !item.foodId); + const generated = new Map(); + + for (const plan of data.plannedMeals) { + if (dateSet && !dateSet.has(plan.date)) continue; + const recipe = plan.recipeId ? recipeById.get(plan.recipeId) : undefined; + if (!recipe) continue; + + for (const ingredient of recipe.ingredients) { + const food = foodById.get(ingredient.foodId); + if (!food) continue; + + const previous = previousByFoodId.get(food.id); + const current = generated.get(food.id); + const amountG = ingredient.amountG * plan.portions; + generated.set(food.id, { + id: current?.id ?? previous?.id ?? uid("shop"), + foodId: food.id, + name: food.name, + category: food.category, + amountG: (current?.amountG ?? 0) + amountG, + purchaseAmount: current?.purchaseAmount ?? previous?.purchaseAmount, + checked: current?.checked ?? previous?.checked, + recipeIds: Array.from(new Set([...(current?.recipeIds ?? []), recipe.id])), + }); + } + } + + return [...generated.values(), ...previousManualItems]; +} diff --git a/apps/admin/lib/hellth/storage.ts b/apps/admin/lib/hellth/storage.ts new file mode 100644 index 0000000..76bde59 --- /dev/null +++ b/apps/admin/lib/hellth/storage.ts @@ -0,0 +1,161 @@ +import { + HELLTH_CLUB_KEY, + HELLTH_DATA_KEY, + defaultClubProfile, + defaultGoals, + defaultReminderSettings, + seedActivityTemplates, + seedFoods, + seedPlannedMeals, + seedRecipes, + seedStores, +} from "./constants"; +import type { ClubData, HellthData } from "./types"; + +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 saveHellthData(data: HellthData, userKey?: string | null) { + if (typeof window === "undefined") return; + window.localStorage.setItem(hellthDataStorageKey(userKey), JSON.stringify(data)); +} + +export function hasCompletedHealthProfile(userKey?: string | null) { + return Boolean(loadHellthData(userKey).healthProfile?.completedAt); +} + +import { todayKey } from "./constants"; + +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)); +} diff --git a/apps/admin/lib/hellth/types.ts b/apps/admin/lib/hellth/types.ts new file mode 100644 index 0000000..45324b2 --- /dev/null +++ b/apps/admin/lib/hellth/types.ts @@ -0,0 +1,319 @@ +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 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; +}; diff --git a/packages/api/src/trpc/init.ts b/packages/api/src/trpc/init.ts index de76e5b..9db64b5 100644 --- a/packages/api/src/trpc/init.ts +++ b/packages/api/src/trpc/init.ts @@ -9,6 +9,7 @@ const t = initTRPC.context().create({ }); export const router = t.router; +export const mergeRouters = t.mergeRouters; export const publicProcedure = t.procedure; const TWO_FACTOR_BYPASS_PATHS = new Set([ "security.status", diff --git a/packages/api/src/trpc/routers/health.ts b/packages/api/src/trpc/routers/health.ts deleted file mode 100644 index d802880..0000000 --- a/packages/api/src/trpc/routers/health.ts +++ /dev/null @@ -1,2062 +0,0 @@ -import { schema } from "@hellth/db"; -import { TRPCError } from "@trpc/server"; -import { and, desc, eq, inArray, isNull, or } from "drizzle-orm"; -import { z } from "zod"; -import { buildDailyCoachSignals } from "../../lib/health-coach"; -import { platformProcedure, protectedProcedure, router } from "../init"; -import { - addDays, - auditHealthMutation, - averageNutritionSources, - calcRecipeCalories, - classifyFoodsForDiet, - clubActivityInput, - clubProfileInput, - dateKeySchema, - dateRange, - foodInput, - foodPreferenceInput, - foodText, - getPointSettings, - goalsInput, - hasAnyWord, - id, - mealTypeSchema, - normalizeOpenFoodFactsProduct, - normalizeSearch, - numberString, - nutritionSourceInput, - pointSettingsInput, - profileInput, - recipeInput, - recipeNutrition, - recipeRecommendationInput, - recipeSubmissionReviewInput, - recipeSubmissionStatusSchema, - startOfWeek, - storeChangeRequestInput, - storeChangeReviewInput, - storeFavoriteInput, - storeInput, -} from "./health/_shared"; - -export const healthRouter = router({ - pointDefaults: protectedProcedure.query(async ({ ctx }) => getPointSettings(ctx.db)), - - pointSettings: platformProcedure.query(async ({ ctx }) => { - const settings = await getPointSettings(ctx.db); - const templates = await ctx.db.query.activityTemplates.findMany({ - where: (row, { isNull: rowIsNull }) => rowIsNull(row.ownerUserId), - orderBy: (row, { asc }) => [asc(row.category), asc(row.name)], - }); - return { - ...settings, - activityTemplates: templates, - }; - }), - - updatePointSettings: platformProcedure.input(pointSettingsInput).mutation(async ({ ctx, input }) => { - await ctx.db.transaction(async (tx) => { - await tx - .insert(schema.platformSettings) - .values({ - key: "hellth.points", - value: JSON.stringify({ - recipeSubmissionPoints: input.recipeSubmissionPoints, - clubActivityDefaultPoints: input.clubActivityDefaultPoints, - }), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.platformSettings.key, - set: { - value: JSON.stringify({ - recipeSubmissionPoints: input.recipeSubmissionPoints, - clubActivityDefaultPoints: input.clubActivityDefaultPoints, - }), - updatedAt: new Date(), - }, - }); - - for (const template of input.activityTemplates) { - await tx.update(schema.activityTemplates) - .set({ defaultPoints: template.defaultPoints }) - .where(and(eq(schema.activityTemplates.id, template.id), isNull(schema.activityTemplates.ownerUserId))); - } - }); - auditHealthMutation(ctx, "point-settings.update", { - recipeSubmissionPoints: input.recipeSubmissionPoints, - clubActivityDefaultPoints: input.clubActivityDefaultPoints, - templates: input.activityTemplates.length, - }); - return { ok: true }; - }), - - overview: protectedProcedure - .input(z.object({ date: dateKeySchema })) - .query(async ({ ctx, input }) => { - const userId = ctx.user.id; - const [ - profile, - goals, - mealLogs, - drinkLogs, - weightLog, - mealPlans, - activityLogs, - coachRecommendations, - wearableConnections, - ] = await Promise.all([ - ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ctx.db.query.mealLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }), - ctx.db.query.drinkLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }), - ctx.db.query.weightLogs.findFirst({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), - ctx.db.query.mealPlans.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), - ctx.db.query.activityLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }), - ctx.db.query.coachRecommendations.findMany({ where: (row, { and: andWhere, eq: rowEq, isNull: rowIsNull }) => andWhere(rowEq(row.userId, userId), rowIsNull(row.dismissedAt)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], limit: 5 }), - ctx.db.query.wearableConnections.findMany({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ]); - - return { - profile, - goals, - day: { - date: input.date, - mealLogs, - drinkLogs, - weightLog, - mealPlans, - activityLogs, - }, - coachRecommendations, - wearableConnections: wearableConnections.map((connection) => ({ - id: connection.id, - provider: connection.provider, - providerUserId: connection.providerUserId, - scopes: connection.scopes, - lastSyncAt: connection.lastSyncAt, - syncEnabled: connection.syncEnabled, - })), - }; - }), - - upsertProfile: protectedProcedure.input(profileInput).mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - await ctx.db - .insert(schema.healthProfiles) - .values({ - userId, - ...input, - heightCm: numberString(input.heightCm, 0), - currentWeightKg: numberString(input.currentWeightKg, 0), - targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, - completedAt: input.completed ? new Date() : null, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.healthProfiles.userId, - set: { - ...input, - heightCm: numberString(input.heightCm, 0), - currentWeightKg: numberString(input.currentWeightKg, 0), - targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, - completedAt: input.completed ? new Date() : null, - updatedAt: new Date(), - }, - }); - auditHealthMutation(ctx, "profile.upsert", { goal: input.goal, completed: input.completed }); - return { ok: true }; - }), - - upsertGoals: protectedProcedure.input(goalsInput).mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - await ctx.db - .insert(schema.healthGoals) - .values({ - userId, - ...input, - weightKg: input.weightKg ? numberString(input.weightKg) : null, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.healthGoals.userId, - set: { - ...input, - weightKg: input.weightKg ? numberString(input.weightKg) : null, - updatedAt: new Date(), - }, - }); - auditHealthMutation(ctx, "goals.upsert", { - calories: input.calories, - proteinG: input.proteinG, - weeklyCalories: input.weeklyCalories ?? null, - }); - return { ok: true }; - }), - - listFoods: protectedProcedure - .input(z.object({ query: z.string().trim().max(120).optional().default("") })) - .query(async ({ ctx, input }) => { - const userId = ctx.user.id; - const rows = await ctx.db.query.foods.findMany({ - where: or(isNull(schema.foods.ownerUserId), eq(schema.foods.ownerUserId, userId)), - orderBy: [desc(schema.foods.updatedAt)], - limit: input.query ? 200 : 100, - }); - const needle = input.query.toLowerCase(); - const filtered = needle - ? rows.filter((food) => [food.name, food.brand, food.category, food.barcode].filter(Boolean).some((value) => value!.toLowerCase().includes(needle))) - : rows; - const portions = filtered.length - ? await ctx.db.query.foodPortions.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.foodId, filtered.map((food) => food.id)), - }) - : []; - const nutritionSources = filtered.length - ? await ctx.db.query.foodNutritionSources.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.foodId, filtered.map((food) => food.id)), - }) - : []; - const preferences = filtered.length - ? await ctx.db.query.foodUserPreferences.findMany({ - where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) => - andWhere(rowEq(row.userId, userId), rowInArray(row.foodId, filtered.map((food) => food.id))), - }) - : []; - const portionsByFood = new Map(); - for (const portion of portions) { - const current = portionsByFood.get(portion.foodId) ?? []; - current.push(portion); - portionsByFood.set(portion.foodId, current); - } - const sourcesByFood = new Map(); - for (const source of nutritionSources) { - const current = sourcesByFood.get(source.foodId) ?? []; - current.push(source); - sourcesByFood.set(source.foodId, current); - } - const preferencesByFood = new Map(preferences.map((preference) => [preference.foodId, preference])); - return filtered.map((food) => ({ - ...food, - portions: portionsByFood.get(food.id) ?? [], - nutritionSources: sourcesByFood.get(food.id) ?? [], - userVote: preferencesByFood.get(food.id)?.vote ?? 0, - })); - }), - - setFoodPreference: protectedProcedure.input(foodPreferenceInput).mutation(async ({ ctx, input }) => { - const food = await ctx.db.query.foods.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.foodId), - }); - if (!food || (food.ownerUserId && food.ownerUserId !== ctx.user.id)) { - throw new TRPCError({ code: "NOT_FOUND", message: "Lebensmittel wurde nicht gefunden." }); - } - if (input.vote === 0) { - await ctx.db - .delete(schema.foodUserPreferences) - .where(and(eq(schema.foodUserPreferences.userId, ctx.user.id), eq(schema.foodUserPreferences.foodId, input.foodId))); - } else { - await ctx.db - .insert(schema.foodUserPreferences) - .values({ - userId: ctx.user.id, - foodId: input.foodId, - vote: input.vote, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [schema.foodUserPreferences.userId, schema.foodUserPreferences.foodId], - set: { - vote: input.vote, - updatedAt: new Date(), - }, - }); - } - auditHealthMutation(ctx, "food-preference.set", { foodId: input.foodId, vote: input.vote }); - return { ok: true }; - }), - - saveFood: protectedProcedure.input(foodInput).mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - const foodId = input.id ?? id("food"); - const existing = input.id - ? await ctx.db.query.foods.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) - : null; - - if (existing?.ownerUserId && existing.ownerUserId !== userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "Dieses Lebensmittel gehört einem anderen Benutzer." }); - } - - await ctx.db.transaction(async (tx) => { - await tx - .insert(schema.foods) - .values({ - id: foodId, - ownerUserId: existing?.ownerUserId ?? userId, - name: input.name, - brand: input.brand || null, - barcode: input.barcode || null, - category: input.category, - kcalPer100g: numberString(input.kcalPer100g), - proteinPer100g: numberString(input.proteinPer100g), - carbsPer100g: numberString(input.carbsPer100g), - fatPer100g: numberString(input.fatPer100g), - servingG: numberString(input.servingG, 100), - preferredUnit: input.preferredUnit, - densityGPerMl: input.densityGPerMl ? numberString(input.densityGPerMl) : null, - source: input.source, - availableEverywhere: input.availableEverywhere, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.foods.id, - set: { - name: input.name, - brand: input.brand || null, - barcode: input.barcode || null, - category: input.category, - kcalPer100g: numberString(input.kcalPer100g), - proteinPer100g: numberString(input.proteinPer100g), - carbsPer100g: numberString(input.carbsPer100g), - fatPer100g: numberString(input.fatPer100g), - servingG: numberString(input.servingG, 100), - preferredUnit: input.preferredUnit, - densityGPerMl: input.densityGPerMl ? numberString(input.densityGPerMl) : null, - source: input.source, - availableEverywhere: input.availableEverywhere, - updatedAt: new Date(), - }, - }); - - await tx.delete(schema.foodPortions).where(eq(schema.foodPortions.foodId, foodId)); - if (input.portions.length) { - await tx.insert(schema.foodPortions).values(input.portions.map((portion) => ({ - id: portion.id ?? id("portion"), - foodId, - label: portion.label, - unit: portion.unit, - amount: numberString(portion.amount, 1), - gramsEquivalent: numberString(portion.gramsEquivalent, 1), - }))); - } - }); - - auditHealthMutation(ctx, input.id ? "food.update" : "food.create", { - foodId, - name: input.name, - portions: input.portions.length, - }); - return { id: foodId }; - }), - - listStores: protectedProcedure.query(async ({ ctx }) => { - const stores = await ctx.db.query.stores.findMany({ - where: or(isNull(schema.stores.ownerUserId), eq(schema.stores.ownerUserId, ctx.user.id)), - orderBy: (row, { desc: rowDesc, asc }) => [rowDesc(row.favorite), asc(row.name)], - }); - const preferences = stores.length - ? await ctx.db.query.storeUserPreferences.findMany({ - where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) => - andWhere(rowEq(row.userId, ctx.user.id), rowInArray(row.storeId, stores.map((store) => store.id))), - }) - : []; - const departments = stores.length - ? await ctx.db.query.storeDepartments.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.storeId, stores.map((store) => store.id)), - orderBy: (row, { asc }) => [asc(row.sortOrder), asc(row.name)], - }) - : []; - const departmentsByStore = new Map(); - for (const department of departments) { - const current = departmentsByStore.get(department.storeId) ?? []; - current.push(department); - departmentsByStore.set(department.storeId, current); - } - const preferencesByStore = new Map(preferences.map((preference) => [preference.storeId, preference])); - return stores - .map((store) => ({ - ...store, - favorite: preferencesByStore.get(store.id)?.favorite ?? store.favorite, - latitude: store.latitude == null ? null : Number(store.latitude), - longitude: store.longitude == null ? null : Number(store.longitude), - departments: departmentsByStore.get(store.id) ?? [], - })) - .sort((a, b) => Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, "de-DE")); - }), - - setStoreFavorite: protectedProcedure.input(storeFavoriteInput).mutation(async ({ ctx, input }) => { - const store = await ctx.db.query.stores.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.storeId), - }); - if (!store || (store.ownerUserId && store.ownerUserId !== ctx.user.id)) { - throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." }); - } - await ctx.db - .insert(schema.storeUserPreferences) - .values({ - userId: ctx.user.id, - storeId: input.storeId, - favorite: input.favorite, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [schema.storeUserPreferences.userId, schema.storeUserPreferences.storeId], - set: { - favorite: input.favorite, - updatedAt: new Date(), - }, - }); - auditHealthMutation(ctx, "store.favorite", { storeId: input.storeId, favorite: input.favorite }); - return { ok: true }; - }), - - saveStore: protectedProcedure.input(storeInput).mutation(async ({ ctx, input }) => { - const storeId = input.id ?? id("store"); - const existing = input.id - ? await ctx.db.query.stores.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) - : null; - - if (existing && existing.ownerUserId !== ctx.user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "Globale Märkte und Märkte anderer Benutzer können nicht direkt geändert werden." }); - } - - await ctx.db.transaction(async (tx) => { - await tx - .insert(schema.stores) - .values({ - id: storeId, - ownerUserId: existing?.ownerUserId ?? ctx.user.id, - name: input.name, - address: input.address || null, - placeId: input.placeId || null, - latitude: input.latitude == null ? null : numberString(input.latitude), - longitude: input.longitude == null ? null : numberString(input.longitude), - favorite: input.favorite, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.stores.id, - set: { - name: input.name, - address: input.address || null, - placeId: input.placeId || null, - latitude: input.latitude == null ? null : numberString(input.latitude), - longitude: input.longitude == null ? null : numberString(input.longitude), - favorite: input.favorite, - updatedAt: new Date(), - }, - }); - - await tx.delete(schema.storeDepartments).where(eq(schema.storeDepartments.storeId, storeId)); - await tx.insert(schema.storeDepartments).values(input.departments.map((department) => ({ - id: department.id ?? id("department"), - storeId, - name: department.name, - sortOrder: department.sortOrder, - }))); - }); - - auditHealthMutation(ctx, input.id ? "store.update" : "store.create", { - storeId, - departments: input.departments.length, - }); - return { id: storeId }; - }), - - submitStoreChangeRequest: protectedProcedure.input(storeChangeRequestInput).mutation(async ({ ctx, input }) => { - const requestId = id("store-change"); - if (input.storeId) { - const store = await ctx.db.query.stores.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.storeId!), - }); - if (!store) throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." }); - } - await ctx.db.insert(schema.storeChangeRequests).values({ - id: requestId, - userId: ctx.user.id, - storeId: input.storeId || null, - requestType: input.requestType, - status: "pending", - title: input.title, - note: input.note || null, - proposedStore: input.store, - proposedDepartments: input.departments, - updatedAt: new Date(), - }); - auditHealthMutation(ctx, "store-change.submit", { - requestId, - storeId: input.storeId ?? null, - requestType: input.requestType, - }); - return { id: requestId }; - }), - - myStoreChangeRequests: protectedProcedure.query(async ({ ctx }) => { - return ctx.db.query.storeChangeRequests.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - limit: 50, - }); - }), - - reviewStoreChangeRequest: platformProcedure.input(storeChangeReviewInput).mutation(async ({ ctx, input }) => { - const request = await ctx.db.query.storeChangeRequests.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Änderungsantrag wurde nicht gefunden." }); - if (request.status !== "pending") throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Antrag wurde bereits geprüft." }); - - let acceptedStoreId: string | null = null; - await ctx.db.transaction(async (tx) => { - if (input.action === "accept") { - const proposedStore = request.proposedStore as { name?: string; address?: string | null; placeId?: string | null; latitude?: number | null; longitude?: number | null }; - const proposedDepartments = request.proposedDepartments as Array<{ id?: string; name?: string; sortOrder?: number }>; - acceptedStoreId = request.storeId ?? id("store-global"); - await tx.insert(schema.stores).values({ - id: acceptedStoreId, - ownerUserId: null, - name: proposedStore.name || request.title, - address: proposedStore.address || null, - placeId: proposedStore.placeId || null, - latitude: proposedStore.latitude == null ? null : numberString(proposedStore.latitude), - longitude: proposedStore.longitude == null ? null : numberString(proposedStore.longitude), - favorite: false, - updatedAt: new Date(), - }).onConflictDoUpdate({ - target: schema.stores.id, - set: { - ownerUserId: null, - name: proposedStore.name || request.title, - address: proposedStore.address || null, - placeId: proposedStore.placeId || null, - latitude: proposedStore.latitude == null ? null : numberString(proposedStore.latitude), - longitude: proposedStore.longitude == null ? null : numberString(proposedStore.longitude), - updatedAt: new Date(), - }, - }); - await tx.delete(schema.storeDepartments).where(eq(schema.storeDepartments.storeId, acceptedStoreId)); - await tx.insert(schema.storeDepartments).values(proposedDepartments.map((department, index) => ({ - id: department.id ?? id("department"), - storeId: acceptedStoreId!, - name: department.name || `Abteilung ${index + 1}`, - sortOrder: department.sortOrder ?? index + 1, - }))); - } - await tx.update(schema.storeChangeRequests) - .set({ - status: input.action === "accept" ? "accepted" : "rejected", - reviewedBy: ctx.user.id, - reviewedAt: new Date(), - reviewNote: input.reviewNote || null, - acceptedStoreId, - updatedAt: new Date(), - }) - .where(eq(schema.storeChangeRequests.id, input.id)); - }); - auditHealthMutation(ctx, "store-change.review", { requestId: input.id, action: input.action, acceptedStoreId }); - return { ok: true, storeId: acceptedStoreId }; - }), - - deleteStore: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => { - const existing = await ctx.db.query.stores.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." }); - if (existing.ownerUserId !== ctx.user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "Globale Märkte können nicht gelöscht werden." }); - } - await ctx.db.delete(schema.stores).where(eq(schema.stores.id, input.id)); - auditHealthMutation(ctx, "store.delete", { storeId: input.id }); - return { ok: true }; - }), - - searchOpenFoodFacts: protectedProcedure - .input(z.object({ - query: z.string().trim().min(2).max(120), - pageSize: z.number().int().min(1).max(25).default(10), - })) - .query(async ({ input }) => { - const url = new URL("https://world.openfoodfacts.org/cgi/search.pl"); - url.searchParams.set("search_terms", input.query); - url.searchParams.set("search_simple", "1"); - url.searchParams.set("action", "process"); - url.searchParams.set("json", "1"); - url.searchParams.set("page_size", String(input.pageSize)); - url.searchParams.set("fields", "code,product_name,brands,categories,serving_quantity,nutriments"); - - const response = await fetch(url, { - headers: { - "User-Agent": "HellthHub/0.1 (local-home-lab)", - }, - }); - if (!response.ok) { - throw new TRPCError({ code: "BAD_GATEWAY", message: "Open Food Facts konnte nicht abgefragt werden." }); - } - const payload = await response.json() as { products?: Array[0]> }; - return (payload.products ?? []) - .map(normalizeOpenFoodFactsProduct) - .filter((product) => product.name && product.kcalPer100g >= 0); - }), - - addNutritionSource: protectedProcedure.input(nutritionSourceInput).mutation(async ({ ctx, input }) => { - const existing = await ctx.db.query.foods.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.foodId), - }); - if (!existing || (existing.ownerUserId && existing.ownerUserId !== ctx.user.id)) { - throw new TRPCError({ code: "NOT_FOUND", message: "Lebensmittel wurde nicht gefunden." }); - } - - await ctx.db.insert(schema.foodNutritionSources).values({ - id: id("nutrition-source"), - foodId: input.foodId, - provider: input.provider, - providerFoodId: input.providerFoodId || null, - name: input.name, - brand: input.brand || null, - url: input.url || null, - kcalPer100g: numberString(input.kcalPer100g), - proteinPer100g: numberString(input.proteinPer100g), - carbsPer100g: numberString(input.carbsPer100g), - fatPer100g: numberString(input.fatPer100g), - servingG: input.servingG ? numberString(input.servingG) : null, - confidence: input.confidence, - raw: input.raw, - updatedAt: new Date(), - }); - - const sources = await ctx.db.query.foodNutritionSources.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.foodId, input.foodId), - }); - const average = averageNutritionSources(sources); - await ctx.db.update(schema.foods).set({ - kcalPer100g: numberString(average.kcalPer100g), - proteinPer100g: numberString(average.proteinPer100g), - carbsPer100g: numberString(average.carbsPer100g), - fatPer100g: numberString(average.fatPer100g), - source: "community", - updatedAt: new Date(), - }).where(eq(schema.foods.id, input.foodId)); - - auditHealthMutation(ctx, "food-nutrition-source.add", { - foodId: input.foodId, - provider: input.provider, - sources: sources.length, - }); - return { ok: true, average, sourceCount: sources.length }; - }), - - listRecipes: protectedProcedure - .input(z.object({ query: z.string().trim().max(120).optional().default("") })) - .query(async ({ ctx, input }) => { - const userId = ctx.user.id; - const recipes = await ctx.db.query.recipes.findMany({ - where: (row, { or: orWhere, eq: rowEq }) => orWhere(rowEq(row.ownerUserId, userId), rowEq(row.isPublic, true)), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.updatedAt)], - limit: input.query ? 200 : 100, - }); - const needle = input.query.toLowerCase(); - const filtered = needle - ? recipes.filter((recipe) => [recipe.name, recipe.notes].filter(Boolean).some((value) => value!.toLowerCase().includes(needle))) - : recipes; - const ingredients = await ctx.db.query.recipeIngredients.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, filtered.map((recipe) => recipe.id)), - orderBy: (row, { asc: rowAsc }) => [rowAsc(row.sortOrder)], - }); - const ingredientsByRecipe = new Map(); - for (const ingredient of ingredients) { - const current = ingredientsByRecipe.get(ingredient.recipeId) ?? []; - current.push(ingredient); - ingredientsByRecipe.set(ingredient.recipeId, current); - } - return filtered.map((recipe) => ({ - ...recipe, - ingredients: ingredientsByRecipe.get(recipe.id) ?? [], - })); - }), - - recipeRecommendations: protectedProcedure.input(recipeRecommendationInput).query(async ({ ctx, input }) => { - const userId = ctx.user.id; - const profile = await ctx.db.query.healthProfiles.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.userId, userId), - }); - const preferredMealPrepDays = input.mealPrepDays ?? profile?.preferredMealPrepDays ?? 0; - const cooksPerDay = input.cooksPerDay ?? profile?.cooksPerDay ?? 1; - const dietPreferences = new Set(profile?.dietPreferences?.length ? profile.dietPreferences : ["vegan", "vegetarian", "meat", "fish"]); - const meatFishFrequency = profile?.meatFishFrequency ?? "flexible"; - - const recipes = await ctx.db.query.recipes.findMany({ - where: (row, { or: orWhere, eq: rowEq }) => orWhere(rowEq(row.ownerUserId, userId), rowEq(row.isPublic, true)), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.updatedAt)], - limit: 250, - }); - const needle = input.query.toLowerCase(); - const filteredRecipes = needle - ? recipes.filter((recipe) => [recipe.name, recipe.notes].filter(Boolean).some((value) => value!.toLowerCase().includes(needle))) - : recipes; - if (!filteredRecipes.length) return []; - - const ingredients = await ctx.db.query.recipeIngredients.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, filteredRecipes.map((recipe) => recipe.id)), - orderBy: (row, { asc }) => [asc(row.sortOrder)], - }); - const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))]; - const [foods, preferences] = await Promise.all([ - foodIds.length ? ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) : [], - foodIds.length - ? ctx.db.query.foodUserPreferences.findMany({ - where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) => - andWhere(rowEq(row.userId, userId), rowInArray(row.foodId, foodIds)), - }) - : [], - ]); - const foodById = new Map(foods.map((food) => [food.id, food])); - const voteByFood = new Map(preferences.map((preference) => [preference.foodId, preference.vote])); - const ingredientsByRecipe = new Map(); - for (const ingredient of ingredients) { - const current = ingredientsByRecipe.get(ingredient.recipeId) ?? []; - current.push(ingredient); - ingredientsByRecipe.set(ingredient.recipeId, current); - } - - return filteredRecipes - .map((recipe) => { - const recipeIngredients = ingredientsByRecipe.get(recipe.id) ?? []; - const recipeFoods = recipeIngredients.map((ingredient) => foodById.get(ingredient.foodId)).filter(Boolean) as typeof foods; - const diet = classifyFoodsForDiet(recipeFoods); - const containsDislikedFood = recipeIngredients.some((ingredient) => voteByFood.get(ingredient.foodId) === -1); - const likedCount = recipeIngredients.filter((ingredient) => voteByFood.get(ingredient.foodId) === 1).length; - const blockedByDiet = - (diet.hasAnimal && !dietPreferences.has("vegetarian") && !dietPreferences.has("meat") && !dietPreferences.has("fish")) || - (diet.hasMeat && !dietPreferences.has("meat")) || - (diet.hasFish && !dietPreferences.has("fish")) || - (!diet.hasAnimal && !dietPreferences.has("vegan")); - if (blockedByDiet || containsDislikedFood) return null; - - const nutrition = recipeNutrition(recipe, recipeIngredients, foods); - const mealPrepFit = preferredMealPrepDays === 0 - ? recipe.mealPrepDays <= 1 ? 0 : 1 - : Math.abs(recipe.mealPrepDays - preferredMealPrepDays); - let score = 70; - score += likedCount * 12; - score += Math.max(0, 18 - mealPrepFit * 6); - score += recipe.minutes <= 25 ? 10 : recipe.minutes <= 45 ? 4 : -4; - score += nutrition.protein >= 25 ? 8 : nutrition.protein >= 15 ? 4 : 0; - score += nutrition.kcal <= 750 ? 6 : -4; - if (meatFishFrequency === "none" && (diet.hasMeat || diet.hasFish)) score -= 50; - if (meatFishFrequency === "once_per_day" && (diet.hasMeat || diet.hasFish)) score -= 8; - if (cooksPerDay === 1 && recipe.mealPrepDays >= 2) score += 8; - if (cooksPerDay >= 2 && recipe.mealPrepDays <= 1) score += 4; - - return { - ...recipe, - ingredients: recipeIngredients.map((ingredient) => ({ - ...ingredient, - food: foodById.get(ingredient.foodId) ?? null, - userVote: voteByFood.get(ingredient.foodId) ?? 0, - })), - nutrition, - tags: diet.tags, - score: Math.max(0, Math.round(score)), - reasons: [ - preferredMealPrepDays > 0 ? `passt zu Mealprep ${preferredMealPrepDays} Tage` : "passt zu frischer Tagesplanung", - cooksPerDay === 1 ? "für 1x Kochen am Tag priorisiert" : "für flexible Kochfenster priorisiert", - likedCount > 0 ? `${likedCount} bevorzugte Lebensmittel enthalten` : undefined, - nutrition.protein >= 25 ? "proteinreich" : undefined, - ].filter(Boolean) as string[], - }; - }) - .filter((recommendation): recommendation is Exclude => recommendation !== null) - .sort((a, b) => b!.score - a!.score) - .slice(0, input.limit); - }), - - saveRecipe: protectedProcedure.input(recipeInput).mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - const recipeId = input.id ?? id("recipe"); - const existing = input.id - ? await ctx.db.query.recipes.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) - : null; - - if (existing && existing.ownerUserId !== userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "Dieses Rezept gehört einem anderen Benutzer." }); - } - - await ctx.db.transaction(async (tx) => { - await tx - .insert(schema.recipes) - .values({ - id: recipeId, - ownerUserId: userId, - name: input.name, - portions: input.portions, - minutes: input.minutes, - notes: input.notes || null, - mealPrepDays: input.mealPrepDays, - isPublic: input.isPublic, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.recipes.id, - set: { - name: input.name, - portions: input.portions, - minutes: input.minutes, - notes: input.notes || null, - mealPrepDays: input.mealPrepDays, - isPublic: input.isPublic, - updatedAt: new Date(), - }, - }); - - await tx.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, recipeId)); - await tx.insert(schema.recipeIngredients).values(input.ingredients.map((ingredient, index) => ({ - id: ingredient.id ?? id("ingredient"), - recipeId, - foodId: ingredient.foodId, - amountG: numberString(ingredient.amountG), - displayAmount: ingredient.displayAmount ? numberString(ingredient.displayAmount) : null, - displayUnit: ingredient.displayUnit ?? null, - portionId: ingredient.portionId ?? null, - sortOrder: ingredient.sortOrder ?? index, - }))); - }); - - auditHealthMutation(ctx, input.id ? "recipe.update" : "recipe.create", { - recipeId, - ingredients: input.ingredients.length, - mealPrepDays: input.mealPrepDays, - }); - return { id: recipeId }; - }), - - submitRecipeSuggestion: protectedProcedure.input(recipeInput.omit({ id: true, isPublic: true })).mutation(async ({ ctx, input }) => { - const submissionId = id("recipe-submission"); - const pointSettings = await getPointSettings(ctx.db); - const recipeSubmissionPoints = pointSettings.recipeSubmissionPoints; - await ctx.db.transaction(async (tx) => { - await tx.insert(schema.recipeSubmissions).values({ - id: submissionId, - userId: ctx.user.id, - name: input.name, - portions: input.portions, - minutes: input.minutes, - notes: input.notes || null, - mealPrepDays: input.mealPrepDays, - status: "pending", - updatedAt: new Date(), - }); - await tx.insert(schema.recipeSubmissionIngredients).values(input.ingredients.map((ingredient, index) => ({ - id: ingredient.id ?? id("submission-ingredient"), - submissionId, - foodId: ingredient.foodId, - amountG: numberString(ingredient.amountG), - displayAmount: ingredient.displayAmount ? numberString(ingredient.displayAmount) : null, - displayUnit: ingredient.displayUnit ?? null, - portionId: ingredient.portionId ?? null, - sortOrder: ingredient.sortOrder ?? index, - }))); - await tx.insert(schema.activityLogs).values({ - id: id("activity-log"), - userId: ctx.user.id, - date: new Date().toISOString().slice(0, 10), - name: `Rezeptvorschlag: ${input.name}`, - minutes: 0, - points: recipeSubmissionPoints, - calories: 0, - source: "system", - externalId: `recipe-submission:${submissionId}`, - note: "Punkte für einen eingereichten Rezeptvorschlag", - }).onConflictDoNothing(); - }); - auditHealthMutation(ctx, "recipe-submission.create", { - submissionId, - ingredients: input.ingredients.length, - points: recipeSubmissionPoints, - }); - return { id: submissionId, points: recipeSubmissionPoints }; - }), - - myRecipeSubmissions: protectedProcedure.query(async ({ ctx }) => { - const submissions = await ctx.db.query.recipeSubmissions.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - limit: 50, - }); - const ingredients = submissions.length - ? await ctx.db.query.recipeSubmissionIngredients.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.submissionId, submissions.map((submission) => submission.id)), - orderBy: (row, { asc }) => [asc(row.sortOrder)], - }) - : []; - const ingredientsBySubmission = new Map(); - for (const ingredient of ingredients) { - const current = ingredientsBySubmission.get(ingredient.submissionId) ?? []; - current.push(ingredient); - ingredientsBySubmission.set(ingredient.submissionId, current); - } - return submissions.map((submission) => ({ - ...submission, - ingredients: ingredientsBySubmission.get(submission.id) ?? [], - })); - }), - - recipeSubmissionsForReview: platformProcedure - .input(z.object({ status: recipeSubmissionStatusSchema.optional().default("pending") })) - .query(async ({ ctx, input }) => { - const submissions = await ctx.db.query.recipeSubmissions.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.status, input.status), - orderBy: (row, { asc }) => [asc(row.createdAt)], - limit: 100, - }); - const ingredients = submissions.length - ? await ctx.db.query.recipeSubmissionIngredients.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.submissionId, submissions.map((submission) => submission.id)), - orderBy: (row, { asc }) => [asc(row.sortOrder)], - }) - : []; - const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))]; - const foods = foodIds.length - ? await ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) - : []; - const foodById = new Map(foods.map((food) => [food.id, food])); - const ingredientsBySubmission = new Map(); - for (const ingredient of ingredients) { - const current = ingredientsBySubmission.get(ingredient.submissionId) ?? []; - current.push(ingredient); - ingredientsBySubmission.set(ingredient.submissionId, current); - } - return submissions.map((submission) => ({ - ...submission, - ingredients: (ingredientsBySubmission.get(submission.id) ?? []).map((ingredient) => ({ - ...ingredient, - food: foodById.get(ingredient.foodId) ?? null, - })), - })); - }), - - adminActivityFeed: platformProcedure.query(async ({ ctx }) => { - const [recipeSubmissions, storeChangeRequests] = await Promise.all([ - ctx.db.query.recipeSubmissions.findMany({ - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - limit: 50, - }), - ctx.db.query.storeChangeRequests.findMany({ - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - limit: 50, - }), - ]); - const userIds = [...new Set([ - ...recipeSubmissions.map((item) => item.userId), - ...storeChangeRequests.map((item) => item.userId), - ])]; - const users = userIds.length - ? await ctx.db.query.user.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds), - columns: { id: true, name: true, email: true }, - }) - : []; - const userById = new Map(users.map((user) => [user.id, user])); - const recipeItems = recipeSubmissions.map((submission) => { - const actor = userById.get(submission.userId); - return { - id: submission.id, - type: "recipe_submission" as const, - title: submission.name, - status: submission.status, - actorName: actor?.name || actor?.email || "Unbekannter Benutzer", - actorEmail: actor?.email ?? null, - createdAt: submission.createdAt.toISOString(), - href: "/rezepte/pruefung", - detail: `${submission.minutes} min · ${submission.portions} Portion(en)`, - }; - }); - const storeItems = storeChangeRequests.map((request) => { - const actor = userById.get(request.userId); - return { - id: request.id, - type: "store_change" as const, - title: request.title, - status: request.status, - actorName: actor?.name || actor?.email || "Unbekannter Benutzer", - actorEmail: actor?.email ?? null, - createdAt: request.createdAt.toISOString(), - href: "/maerkte", - detail: request.note || request.requestType, - }; - }); - return [...recipeItems, ...storeItems] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 80); - }), - - reviewRecipeSubmission: platformProcedure.input(recipeSubmissionReviewInput).mutation(async ({ ctx, input }) => { - const submission = await ctx.db.query.recipeSubmissions.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!submission) throw new TRPCError({ code: "NOT_FOUND", message: "Rezeptvorschlag wurde nicht gefunden." }); - if (submission.status !== "pending") { - throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Rezeptvorschlag wurde bereits geprüft." }); - } - - let acceptedRecipeId: string | null = null; - await ctx.db.transaction(async (tx) => { - if (input.action === "accept") { - const ingredients = await tx.query.recipeSubmissionIngredients.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.submissionId, input.id), - orderBy: (row, { asc }) => [asc(row.sortOrder)], - }); - acceptedRecipeId = id("recipe"); - await tx.insert(schema.recipes).values({ - id: acceptedRecipeId, - ownerUserId: submission.userId, - name: submission.name, - portions: submission.portions, - minutes: submission.minutes, - notes: submission.notes, - mealPrepDays: submission.mealPrepDays, - isPublic: true, - updatedAt: new Date(), - }); - await tx.insert(schema.recipeIngredients).values(ingredients.map((ingredient, index) => ({ - id: id("ingredient"), - recipeId: acceptedRecipeId!, - foodId: ingredient.foodId, - amountG: ingredient.amountG, - displayAmount: ingredient.displayAmount, - displayUnit: ingredient.displayUnit, - portionId: ingredient.portionId, - sortOrder: ingredient.sortOrder ?? index, - }))); - } - - await tx.update(schema.recipeSubmissions) - .set({ - status: input.action === "accept" ? "accepted" : "rejected", - reviewNote: input.reviewNote || null, - reviewedBy: ctx.user.id, - reviewedAt: new Date(), - acceptedRecipeId, - updatedAt: new Date(), - }) - .where(eq(schema.recipeSubmissions.id, input.id)); - }); - - auditHealthMutation(ctx, "recipe-submission.review", { - submissionId: input.id, - action: input.action, - acceptedRecipeId, - }); - return { ok: true, recipeId: acceptedRecipeId }; - }), - - addDrinkLog: protectedProcedure - .input(z.object({ date: dateKeySchema, amountMl: z.number().int().positive().max(5000) })) - .mutation(async ({ ctx, input }) => { - await ctx.db.insert(schema.drinkLogs).values({ - id: id("drink"), - userId: ctx.user.id, - date: input.date, - amountMl: input.amountMl, - }); - auditHealthMutation(ctx, "drink.add", { date: input.date, amountMl: input.amountMl }); - return { ok: true }; - }), - - upsertWeightLog: protectedProcedure - .input(z.object({ date: dateKeySchema, weightKg: z.number().positive().max(500), note: z.string().max(1000).optional().nullable() })) - .mutation(async ({ ctx, input }) => { - await ctx.db - .insert(schema.weightLogs) - .values({ - id: id("weight"), - userId: ctx.user.id, - date: input.date, - weightKg: numberString(input.weightKg), - note: input.note || null, - }) - .onConflictDoUpdate({ - target: [schema.weightLogs.userId, schema.weightLogs.date], - set: { - weightKg: numberString(input.weightKg), - note: input.note || null, - createdAt: new Date(), - }, - }); - auditHealthMutation(ctx, "weight.upsert", { date: input.date, weightKg: input.weightKg }); - return { ok: true }; - }), - - weightHistory: protectedProcedure - .input(z.object({ - startsOn: dateKeySchema.optional(), - endsOn: dateKeySchema.optional(), - limit: z.number().int().min(1).max(365).default(90), - }).optional()) - .query(async ({ ctx, input }) => { - const today = new Date().toISOString().slice(0, 10); - const endsOn = input?.endsOn ?? today; - const startsOn = input?.startsOn ?? addDays(endsOn, -(input?.limit ?? 90) + 1); - const [profile, goals, rows] = await Promise.all([ - ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id) }), - ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id) }), - ctx.db.query.weightLogs.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, startsOn), lte(row.date, endsOn)), - orderBy: (row, { asc }) => [asc(row.date)], - limit: input?.limit ?? 90, - }), - ]); - - const entries = rows.map((row) => ({ - id: row.id, - date: row.date, - weightKg: Number(row.weightKg), - note: row.note, - createdAt: row.createdAt, - })); - const first = entries[0]; - const latest = entries.at(-1); - const previous = entries.at(-2); - const targetWeightKg = profile?.targetWeightKg ? Number(profile.targetWeightKg) : goals?.weightKg ? Number(goals.weightKg) : null; - const deltaFromFirst = first && latest ? Math.round((latest.weightKg - first.weightKg) * 10) / 10 : 0; - const deltaFromPrevious = previous && latest ? Math.round((latest.weightKg - previous.weightKg) * 10) / 10 : 0; - - let trendKgPerWeek: number | null = null; - let targetDate: string | null = null; - let projectionMessage = "Noch zu wenig Daten für eine faire Prognose."; - let confidence: "none" | "low" | "medium" = "none"; - - if (entries.length >= 3 && first && latest) { - const elapsedDays = Math.max( - 1, - Math.round((new Date(`${latest.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000), - ); - if (elapsedDays >= 7) { - trendKgPerWeek = Math.round(((latest.weightKg - first.weightKg) / elapsedDays) * 7 * 10) / 10; - confidence = elapsedDays >= 14 && entries.length >= 5 ? "medium" : "low"; - if ( - targetWeightKg && - ((targetWeightKg < latest.weightKg && trendKgPerWeek < -0.05) || - (targetWeightKg > latest.weightKg && trendKgPerWeek > 0.05)) - ) { - const estimatedDays = Math.ceil(Math.abs(latest.weightKg - targetWeightKg) / Math.max(0.01, Math.abs(trendKgPerWeek / 7))); - targetDate = addDays(endsOn, estimatedDays); - projectionMessage = `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`; - } else { - projectionMessage = "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark."; - } - } - } - - return { - startsOn, - endsOn, - entries, - latest, - targetWeightKg, - deltaFromFirst, - deltaFromPrevious, - trendKgPerWeek, - targetDate, - projectionMessage, - confidence, - }; - }), - - deleteWeightLog: protectedProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.query.weightLogs.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!existing || existing.userId !== ctx.user.id) { - throw new TRPCError({ code: "NOT_FOUND", message: "Gewichtseintrag wurde nicht gefunden." }); - } - await ctx.db.delete(schema.weightLogs).where(eq(schema.weightLogs.id, input.id)); - auditHealthMutation(ctx, "weight.delete", { id: input.id, date: existing.date }); - return { ok: true }; - }), - - addMealLog: protectedProcedure - .input(z.object({ - date: dateKeySchema, - mealType: mealTypeSchema, - name: z.string().trim().min(1).max(180), - amountLabel: z.string().trim().max(120).optional().nullable(), - calories: z.number().int().min(0).max(10000), - proteinG: z.number().min(0).max(1000).default(0), - carbsG: z.number().min(0).max(1000).default(0), - fatG: z.number().min(0).max(1000).default(0), - foodId: z.string().optional().nullable(), - recipeId: z.string().optional().nullable(), - })) - .mutation(async ({ ctx, input }) => { - await ctx.db.insert(schema.mealLogs).values({ - id: id("meal"), - userId: ctx.user.id, - date: input.date, - mealType: input.mealType, - foodId: input.foodId || null, - recipeId: input.recipeId || null, - name: input.name, - amountLabel: input.amountLabel || null, - calories: input.calories, - proteinG: numberString(input.proteinG), - carbsG: numberString(input.carbsG), - fatG: numberString(input.fatG), - }); - auditHealthMutation(ctx, "meal.add", { date: input.date, mealType: input.mealType, calories: input.calories }); - return { ok: true }; - }), - - deleteMealLog: protectedProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.query.mealLogs.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!existing || existing.userId !== ctx.user.id) { - throw new TRPCError({ code: "NOT_FOUND", message: "Der Eintrag wurde nicht gefunden." }); - } - await ctx.db.delete(schema.mealLogs).where(eq(schema.mealLogs.id, input.id)); - auditHealthMutation(ctx, "meal.delete", { id: input.id, date: existing.date }); - return { ok: true }; - }), - - deleteDrinkLog: protectedProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.query.drinkLogs.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!existing || existing.userId !== ctx.user.id) { - throw new TRPCError({ code: "NOT_FOUND", message: "Der Eintrag wurde nicht gefunden." }); - } - await ctx.db.delete(schema.drinkLogs).where(eq(schema.drinkLogs.id, input.id)); - auditHealthMutation(ctx, "drink.delete", { id: input.id, date: existing.date }); - return { ok: true }; - }), - - upsertMealPlan: protectedProcedure - .input(z.object({ - id: z.string().optional(), - date: dateKeySchema, - mealType: mealTypeSchema, - recipeId: z.string().optional().nullable(), - name: z.string().trim().min(1).max(180), - portions: z.number().positive().max(100).default(1), - plannedCalories: z.number().int().min(0).max(20000).default(0), - note: z.string().max(1000).optional().nullable(), - })) - .mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - const planId = input.id ?? id("meal-plan"); - const existing = input.id - ? await ctx.db.query.mealPlans.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) - : null; - if (existing && existing.userId !== userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "Diese Planung gehört einem anderen Benutzer." }); - } - await ctx.db - .insert(schema.mealPlans) - .values({ - id: planId, - userId, - date: input.date, - mealType: input.mealType, - recipeId: input.recipeId || null, - name: input.name, - portions: numberString(input.portions), - plannedCalories: input.plannedCalories, - note: input.note || null, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.mealPlans.id, - set: { - date: input.date, - mealType: input.mealType, - recipeId: input.recipeId || null, - name: input.name, - portions: numberString(input.portions), - plannedCalories: input.plannedCalories, - note: input.note || null, - updatedAt: new Date(), - }, - }); - auditHealthMutation(ctx, input.id ? "meal-plan.update" : "meal-plan.create", { - planId, - date: input.date, - mealType: input.mealType, - }); - return { id: planId }; - }), - - deleteMealPlan: protectedProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.query.mealPlans.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!existing || existing.userId !== ctx.user.id) { - throw new TRPCError({ code: "NOT_FOUND", message: "Die Planung wurde nicht gefunden." }); - } - await ctx.db.delete(schema.mealPlans).where(eq(schema.mealPlans.id, input.id)); - auditHealthMutation(ctx, "meal-plan.delete", { id: input.id, date: existing.date }); - return { ok: true }; - }), - - mealPlansRange: protectedProcedure - .input(z.object({ startsOn: dateKeySchema, endsOn: dateKeySchema })) - .query(async ({ ctx, input }) => { - return ctx.db.query.mealPlans.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, input.startsOn), lte(row.date, input.endsOn)), - orderBy: (row, { asc }) => [asc(row.date), asc(row.mealType), asc(row.createdAt)], - }); - }), - - upsertWeeklyCaloriePlan: protectedProcedure - .input(z.object({ date: dateKeySchema, plannedCalories: z.number().int().min(800).max(6000), note: z.string().max(500).optional().nullable() })) - .mutation(async ({ ctx, input }) => { - await ctx.db - .insert(schema.weeklyCaloriePlans) - .values({ - userId: ctx.user.id, - date: input.date, - plannedCalories: input.plannedCalories, - note: input.note || null, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [schema.weeklyCaloriePlans.userId, schema.weeklyCaloriePlans.date], - set: { - plannedCalories: input.plannedCalories, - note: input.note || null, - updatedAt: new Date(), - }, - }); - auditHealthMutation(ctx, "weekly-calorie-plan.upsert", { - date: input.date, - plannedCalories: input.plannedCalories, - }); - return { ok: true }; - }), - - addActivityLog: protectedProcedure - .input(z.object({ - date: dateKeySchema, - name: z.string().trim().min(1).max(180), - templateId: z.string().max(180).optional().nullable(), - minutes: z.number().int().min(1).max(1440), - points: z.number().int().min(0).max(10000), - calories: z.number().int().min(0).max(10000).default(0), - source: z.enum(["manual", "fitbit", "health_connect", "system"]).default("manual"), - externalId: z.string().max(180).optional().nullable(), - note: z.string().max(1000).optional().nullable(), - })) - .mutation(async ({ ctx, input }) => { - await ctx.db.insert(schema.activityLogs).values({ - id: id("activity-log"), - userId: ctx.user.id, - templateId: input.templateId || null, - date: input.date, - name: input.name, - minutes: input.minutes, - points: input.points, - calories: input.calories, - source: input.source, - externalId: input.externalId || null, - note: input.note || null, - }).onConflictDoNothing(); - auditHealthMutation(ctx, "activity.add", { - date: input.date, - source: input.source, - points: input.points, - calories: input.calories, - }); - return { ok: true }; - }), - - activityWeek: protectedProcedure - .input(z.object({ date: dateKeySchema })) - .query(async ({ ctx, input }) => { - const weekStart = startOfWeek(input.date); - const weekEnd = addDays(weekStart, 6); - const logs = await ctx.db.query.activityLogs.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, weekStart), lte(row.date, weekEnd)), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - }); - return { - weekStart, - weekEnd, - logs, - }; - }), - - clubLeaderboard: protectedProcedure - .input(z.object({ days: z.number().int().min(7).max(365).default(90) }).optional()) - .query(async ({ ctx, input }) => { - const startsOn = addDays(new Date().toISOString().slice(0, 10), -(input?.days ?? 90) + 1); - const logs = await ctx.db.query.activityLogs.findMany({ - where: (row, { gte }) => gte(row.date, startsOn), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - }); - const userIds = [...new Set(logs.map((log) => log.userId))]; - const users = userIds.length - ? await ctx.db.query.user.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds), - columns: { id: true, name: true, email: true }, - }) - : []; - const userById = new Map(users.map((user) => [user.id, user])); - const scores = new Map(); - for (const log of logs) { - if (log.points <= 0) continue; - const user = userById.get(log.userId); - const current = scores.get(log.userId) ?? { - userKey: log.userId, - name: user?.name || user?.email || "Hellth User", - points: 0, - completed: 0, - }; - scores.set(log.userId, { - ...current, - points: current.points + log.points, - completed: current.completed + 1, - }); - } - return [...scores.values()] - .sort((a, b) => b.points - a.points || b.completed - a.completed) - .slice(0, 20); - }), - - deleteActivityLog: protectedProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - await ctx.db - .delete(schema.activityLogs) - .where(and(eq(schema.activityLogs.id, input.id), eq(schema.activityLogs.userId, ctx.user.id))); - auditHealthMutation(ctx, "activity.delete", { activityLogId: input.id }); - return { ok: true }; - }), - - listActivityTemplates: protectedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; - return ctx.db.query.activityTemplates.findMany({ - where: (row, { or: orWhere, eq: rowEq, isNull: rowIsNull }) => orWhere(rowIsNull(row.ownerUserId), rowEq(row.ownerUserId, userId)), - orderBy: (row, { asc: rowAsc }) => [rowAsc(row.name)], - }); - }), - - saveActivityTemplate: protectedProcedure - .input(z.object({ - id: z.string().optional(), - name: z.string().trim().min(1).max(180), - category: z.string().trim().min(1).max(80).default("habit"), - defaultMinutes: z.number().int().min(1).max(1440), - defaultPoints: z.number().int().min(0).max(10000), - defaultCalories: z.number().int().min(0).max(10000).default(0), - icon: z.string().trim().max(80).default("custom"), - })) - .mutation(async ({ ctx, input }) => { - const templateId = input.id ?? id("activity"); - const existing = input.id - ? await ctx.db.query.activityTemplates.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) - : null; - if (existing?.ownerUserId && existing.ownerUserId !== ctx.user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "Diese Aktivitätsvorlage gehört einem anderen Benutzer." }); - } - await ctx.db - .insert(schema.activityTemplates) - .values({ - id: templateId, - ownerUserId: existing?.ownerUserId ?? ctx.user.id, - name: input.name, - category: input.category, - defaultMinutes: input.defaultMinutes, - defaultPoints: input.defaultPoints, - defaultCalories: input.defaultCalories, - icon: input.icon, - }) - .onConflictDoUpdate({ - target: schema.activityTemplates.id, - set: { - name: input.name, - category: input.category, - defaultMinutes: input.defaultMinutes, - defaultPoints: input.defaultPoints, - defaultCalories: input.defaultCalories, - icon: input.icon, - }, - }); - auditHealthMutation(ctx, input.id ? "activity-template.update" : "activity-template.create", { templateId }); - return { id: templateId }; - }), - - generateShoppingList: protectedProcedure - .input(z.object({ - startsOn: dateKeySchema, - endsOn: dateKeySchema, - storeId: z.string().optional().nullable(), - name: z.string().trim().max(180).optional(), - })) - .mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - const plans = await ctx.db.query.mealPlans.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, input.startsOn), lte(row.date, input.endsOn)), - }); - const recipeIds = [...new Set(plans.map((plan) => plan.recipeId).filter(Boolean))] as string[]; - const ingredients = recipeIds.length - ? await ctx.db.query.recipeIngredients.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, recipeIds), - }) - : []; - const foods = ingredients.length - ? await ctx.db.query.foods.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.id, [...new Set(ingredients.map((ingredient) => ingredient.foodId))]), - }) - : []; - const foodById = new Map(foods.map((food) => [food.id, food])); - const planPortionsByRecipeId = new Map(); - for (const plan of plans) { - if (!plan.recipeId) continue; - planPortionsByRecipeId.set( - plan.recipeId, - (planPortionsByRecipeId.get(plan.recipeId) ?? 0) + Number(plan.portions ?? 1), - ); - } - const amountByFood = new Map(); - for (const ingredient of ingredients) { - const food = foodById.get(ingredient.foodId); - const portions = planPortionsByRecipeId.get(ingredient.recipeId) ?? 1; - const current = amountByFood.get(ingredient.foodId) ?? { - foodId: ingredient.foodId, - name: food?.name ?? "Unbekanntes Lebensmittel", - category: food?.category ?? "Sonstiges", - amountG: 0, - }; - current.amountG += Number(ingredient.amountG ?? 0) * portions; - amountByFood.set(ingredient.foodId, current); - } - - const shoppingListId = id("shopping"); - await ctx.db.transaction(async (tx) => { - await tx.insert(schema.shoppingLists).values({ - id: shoppingListId, - userId, - storeId: input.storeId ?? null, - name: input.name || `Einkauf ${input.startsOn} bis ${input.endsOn}`, - startsOn: input.startsOn, - endsOn: input.endsOn, - }); - const items = [...amountByFood.values()]; - if (items.length) { - await tx.insert(schema.shoppingListItems).values(items.map((item, index) => ({ - id: id("shopping-item"), - shoppingListId, - foodId: item.foodId, - name: item.name, - category: item.category, - amountG: numberString(Math.round(item.amountG)), - purchaseAmount: `${Math.round(item.amountG)} g`, - checked: false, - sortOrder: index, - }))); - } - }); - auditHealthMutation(ctx, "shopping-list.generate", { - shoppingListId, - startsOn: input.startsOn, - endsOn: input.endsOn, - itemCount: amountByFood.size, - }); - return { id: shoppingListId, itemCount: amountByFood.size }; - }), - - shoppingLists: protectedProcedure - .input(z.object({ limit: z.number().int().min(1).max(20).default(5) }).optional()) - .query(async ({ ctx, input }) => { - const lists = await ctx.db.query.shoppingLists.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), - orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], - limit: input?.limit ?? 5, - }); - const items = lists.length - ? await ctx.db.query.shoppingListItems.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.shoppingListId, lists.map((list) => list.id)), - orderBy: (row, { asc }) => [asc(row.sortOrder), asc(row.name)], - }) - : []; - const itemsByList = new Map(); - for (const item of items) { - const current = itemsByList.get(item.shoppingListId) ?? []; - current.push(item); - itemsByList.set(item.shoppingListId, current); - } - return lists.map((list) => ({ ...list, items: itemsByList.get(list.id) ?? [] })); - }), - - updateShoppingListItem: protectedProcedure - .input(z.object({ - id: z.string().min(1), - checked: z.boolean().optional(), - purchaseAmount: z.string().trim().max(120).optional().nullable(), - })) - .mutation(async ({ ctx, input }) => { - const existing = await ctx.db - .select({ - id: schema.shoppingListItems.id, - listUserId: schema.shoppingLists.userId, - }) - .from(schema.shoppingListItems) - .innerJoin(schema.shoppingLists, eq(schema.shoppingListItems.shoppingListId, schema.shoppingLists.id)) - .where(eq(schema.shoppingListItems.id, input.id)) - .limit(1); - if (!existing[0] || existing[0].listUserId !== ctx.user.id) { - throw new TRPCError({ code: "NOT_FOUND", message: "Einkaufslisten-Eintrag wurde nicht gefunden." }); - } - await ctx.db - .update(schema.shoppingListItems) - .set({ - ...(input.checked !== undefined ? { checked: input.checked } : {}), - ...(input.purchaseAmount !== undefined ? { purchaseAmount: input.purchaseAmount || null } : {}), - }) - .where(eq(schema.shoppingListItems.id, input.id)); - auditHealthMutation(ctx, "shopping-list-item.update", { itemId: input.id }); - return { ok: true }; - }), - - clubProfile: protectedProcedure.query(async ({ ctx }) => { - const profile = await ctx.db.query.clubProfiles.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), - }); - return profile ?? { - userId: ctx.user.id, - state: "Bayern", - city: "Augsburg", - latitude: null, - longitude: null, - privacyMode: "state", - updatedAt: new Date(), - }; - }), - - upsertClubProfile: protectedProcedure.input(clubProfileInput).mutation(async ({ ctx, input }) => { - await ctx.db - .insert(schema.clubProfiles) - .values({ - userId: ctx.user.id, - state: input.state, - city: input.city || null, - latitude: input.latitude == null ? null : numberString(input.latitude), - longitude: input.longitude == null ? null : numberString(input.longitude), - privacyMode: input.privacyMode, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.clubProfiles.userId, - set: { - state: input.state, - city: input.city || null, - latitude: input.latitude == null ? null : numberString(input.latitude), - longitude: input.longitude == null ? null : numberString(input.longitude), - privacyMode: input.privacyMode, - updatedAt: new Date(), - }, - }); - auditHealthMutation(ctx, "club-profile.upsert", { state: input.state, privacyMode: input.privacyMode }); - return { ok: true }; - }), - - clubActivities: protectedProcedure - .input(z.object({ startsOn: dateKeySchema.optional(), endsOn: dateKeySchema.optional() }).optional()) - .query(async ({ ctx, input }) => { - const startsOn = input?.startsOn ?? addDays(new Date().toISOString().slice(0, 10), -7); - const endsOn = input?.endsOn ?? addDays(new Date().toISOString().slice(0, 10), 30); - const activities = await ctx.db.query.clubActivities.findMany({ - where: (row, { and: andWhere, gte, lte }) => - andWhere(gte(row.startsAt, new Date(`${startsOn}T00:00:00`)), lte(row.startsAt, new Date(`${endsOn}T23:59:59`))), - orderBy: (row, { asc }) => [asc(row.startsAt)], - }); - const participants = activities.length - ? await ctx.db.query.clubActivityParticipants.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.activityId, activities.map((activity) => activity.id)), - }) - : []; - const userIds = [...new Set([...activities.map((activity) => activity.hostUserId), ...participants.map((participant) => participant.userId)])]; - const users = userIds.length - ? await ctx.db.query.user.findMany({ - where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds), - columns: { id: true, name: true, email: true }, - }) - : []; - const userById = new Map(users.map((user) => [user.id, user])); - const participantsByActivity = new Map(); - for (const participant of participants) { - const current = participantsByActivity.get(participant.activityId) ?? []; - current.push(participant); - participantsByActivity.set(participant.activityId, current); - } - return activities.map((activity) => { - const host = userById.get(activity.hostUserId); - return { - ...activity, - startsAt: activity.startsAt.toISOString(), - latitude: activity.latitude == null ? null : Number(activity.latitude), - longitude: activity.longitude == null ? null : Number(activity.longitude), - hostName: host?.name || host?.email || "Hellth User", - participants: (participantsByActivity.get(activity.id) ?? []).map((participant) => { - const participantUser = userById.get(participant.userId); - return { - ...participant, - displayName: participant.displayName || participantUser?.name || participantUser?.email || "Hellth User", - joinedAt: participant.joinedAt.toISOString(), - completedAt: participant.completedAt?.toISOString() ?? null, - }; - }), - }; - }); - }), - - createClubActivity: protectedProcedure.input(clubActivityInput).mutation(async ({ ctx, input }) => { - const activityId = id("club"); - await ctx.db.transaction(async (tx) => { - await tx.insert(schema.clubActivities).values({ - id: activityId, - hostUserId: ctx.user.id, - title: input.title, - category: input.category, - scope: input.scope, - state: input.state || null, - city: input.city || null, - latitude: input.latitude == null ? null : numberString(input.latitude), - longitude: input.longitude == null ? null : numberString(input.longitude), - locationLabel: input.locationLabel || null, - startsAt: new Date(input.startsAt), - durationMinutes: input.durationMinutes, - points: input.points, - note: input.note || null, - updatedAt: new Date(), - }); - await tx.insert(schema.clubActivityParticipants).values({ - activityId, - userId: ctx.user.id, - displayName: ctx.user.name || ctx.user.email || "Du", - joinedAt: new Date(), - }); - }); - auditHealthMutation(ctx, "club-activity.create", { activityId, scope: input.scope, category: input.category }); - return { id: activityId }; - }), - - joinClubActivity: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => { - const activity = await ctx.db.query.clubActivities.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!activity) throw new TRPCError({ code: "NOT_FOUND", message: "Aktivität wurde nicht gefunden." }); - await ctx.db.insert(schema.clubActivityParticipants).values({ - activityId: input.id, - userId: ctx.user.id, - displayName: ctx.user.name || ctx.user.email || "Du", - joinedAt: new Date(), - }).onConflictDoNothing(); - auditHealthMutation(ctx, "club-activity.join", { activityId: input.id }); - return { ok: true }; - }), - - completeClubActivity: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => { - const activity = await ctx.db.query.clubActivities.findFirst({ - where: (row, { eq: rowEq }) => rowEq(row.id, input.id), - }); - if (!activity) throw new TRPCError({ code: "NOT_FOUND", message: "Aktivität wurde nicht gefunden." }); - await ctx.db.transaction(async (tx) => { - await tx.insert(schema.clubActivityParticipants).values({ - activityId: input.id, - userId: ctx.user.id, - displayName: ctx.user.name || ctx.user.email || "Du", - joinedAt: new Date(), - completedAt: new Date(), - }).onConflictDoUpdate({ - target: [schema.clubActivityParticipants.activityId, schema.clubActivityParticipants.userId], - set: { completedAt: new Date() }, - }); - await tx.insert(schema.activityLogs).values({ - id: id("activity-log"), - userId: ctx.user.id, - date: activity.startsAt.toISOString().slice(0, 10), - name: `Hellfireclub: ${activity.title}`, - minutes: activity.durationMinutes, - points: activity.points, - calories: Math.round(activity.durationMinutes * 4), - source: "system", - note: "Gemeinsam getrennt im Hellfireclub", - }); - }); - auditHealthMutation(ctx, "club-activity.complete", { activityId: input.id, points: activity.points }); - return { ok: true }; - }), - - wearableConnections: protectedProcedure.query(async ({ ctx }) => { - const rows = await ctx.db.query.wearableConnections.findMany({ - where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), - }); - return rows.map((row) => ({ - id: row.id, - provider: row.provider, - providerUserId: row.providerUserId, - scopes: row.scopes, - expiresAt: row.expiresAt, - lastSyncAt: row.lastSyncAt, - syncEnabled: row.syncEnabled, - })); - }), - - statsSummary: protectedProcedure - .input(z.object({ referenceDate: dateKeySchema })) - .query(async ({ ctx, input }) => { - const userId = ctx.user.id; - const days = dateRange(input.referenceDate, 14); - const startsOn = days[0]!; - const endsOn = days[days.length - 1]!; - const weekStart = startOfWeek(input.referenceDate); - const weekDays = dateRange(addDays(weekStart, 6), 7); - - const [profile, goals, mealLogs, drinkLogs, weightLogs, activityLogs, mealPlans, weeklyPlans] = await Promise.all([ - ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ctx.db.query.mealLogs.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), - }), - ctx.db.query.drinkLogs.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), - }), - ctx.db.query.weightLogs.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), - orderBy: (row, { asc }) => [asc(row.date)], - }), - ctx.db.query.activityLogs.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), - }), - ctx.db.query.mealPlans.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, weekStart), lte(row.date, addDays(weekStart, 6))), - }), - ctx.db.query.weeklyCaloriePlans.findMany({ - where: (row, { and: andWhere, eq: rowEq, gte, lte }) => - andWhere(rowEq(row.userId, userId), gte(row.date, weekStart), lte(row.date, addDays(weekStart, 6))), - }), - ]); - - const recipeIds = [...new Set(mealPlans.map((plan) => plan.recipeId).filter(Boolean))] as string[]; - const recipes = recipeIds.length - ? await ctx.db.query.recipes.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, recipeIds) }) - : []; - const ingredients = recipeIds.length - ? await ctx.db.query.recipeIngredients.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, recipeIds) }) - : []; - const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))]; - const foods = foodIds.length - ? await ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) - : []; - - const recipeCalories = new Map(recipes.map((recipe) => [recipe.id, calcRecipeCalories(recipe, ingredients, foods)])); - const plannedCaloriesByDate = new Map(); - for (const plan of mealPlans) { - const explicitCalories = Number(plan.plannedCalories ?? 0); - const recipeCaloriesForPlan = plan.recipeId ? recipeCalories.get(plan.recipeId) ?? 0 : 0; - const kcal = explicitCalories > 0 ? explicitCalories : recipeCaloriesForPlan * Number(plan.portions ?? 1); - plannedCaloriesByDate.set(plan.date, (plannedCaloriesByDate.get(plan.date) ?? 0) + kcal); - } - - const dayRows = days.map((date) => { - const loggedMeals = mealLogs.filter((meal) => meal.date === date); - const loggedCalories = loggedMeals.reduce((sum, meal) => sum + meal.calories, 0); - return { - date, - calories: loggedCalories || (date >= input.referenceDate ? Math.round(plannedCaloriesByDate.get(date) ?? 0) : 0), - plannedCalories: Math.round(plannedCaloriesByDate.get(date) ?? 0), - activityCalories: activityLogs.filter((log) => log.date === date).reduce((sum, log) => sum + log.calories, 0), - protein: Math.round(loggedMeals.reduce((sum, meal) => sum + Number(meal.proteinG ?? 0), 0)), - drinkMl: drinkLogs.filter((drink) => drink.date === date).reduce((sum, drink) => sum + drink.amountMl, 0), - weightKg: (() => { - const weight = weightLogs.find((log) => log.date === date); - return weight ? Number(weight.weightKg) : undefined; - })(), - }; - }); - - const relevantDays = dayRows.filter((day) => day.date <= input.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 streak = (predicate: (day: typeof dayRows[number]) => boolean) => { - let count = 0; - for (const day of [...relevantDays].reverse()) { - if (!predicate(day)) break; - count += 1; - } - return count; - }; - const calorieGoal = goals?.calories ?? 2000; - const drinkGoal = goals?.drinkMl ?? 2500; - const proteinGoal = goals?.proteinG ?? 150; - const weeklyPlanByDate = new Map(weeklyPlans.map((plan) => [plan.date, plan])); - const weekDayRows = weekDays.map((date) => { - const plannedCalories = weeklyPlanByDate.get(date)?.plannedCalories ?? calorieGoal; - const eatenCalories = Math.round(plannedCaloriesByDate.get(date) ?? 0); - const activityCalories = activityLogs.filter((log) => log.date === date).reduce((sum, log) => sum + log.calories, 0); - return { - date, - plannedCalories, - eatenCalories, - activityCalories, - balance: plannedCalories + activityCalories - eatenCalories, - note: weeklyPlanByDate.get(date)?.note ?? undefined, - }; - }); - const plannedBudget = weekDayRows.reduce((sum, day) => sum + day.plannedCalories, 0); - const eatenCalories = weekDayRows.reduce((sum, day) => sum + day.eatenCalories, 0); - const activityCalories = weekDayRows.reduce((sum, day) => sum + day.activityCalories, 0); - - const weightEntries = dayRows.filter((day) => day.weightKg !== undefined); - let projection: { kind: "weight"; trendKgPerWeek: number; targetDate?: string; message: string; confidence: "low" | "medium" } | undefined; - if (weightEntries.length >= 3) { - const first = weightEntries[0]!; - const last = weightEntries[weightEntries.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) { - const trendKgPerWeek = Math.round(((last.weightKg! - first.weightKg!) / elapsedDays) * 7 * 10) / 10; - const targetWeight = profile?.targetWeightKg ? Number(profile.targetWeightKg) : goals?.weightKg ? Number(goals.weightKg) : undefined; - if (targetWeight && ((targetWeight < last.weightKg! && trendKgPerWeek < -0.05) || (targetWeight > last.weightKg! && trendKgPerWeek > 0.05))) { - const estimatedDays = Math.ceil(Math.abs(last.weightKg! - targetWeight) / Math.max(0.01, Math.abs(trendKgPerWeek / 7))); - projection = { - kind: "weight", - trendKgPerWeek, - targetDate: addDays(input.referenceDate, estimatedDays), - confidence: elapsedDays >= 14 && weightEntries.length >= 5 ? "medium" : "low", - message: `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`, - }; - } else { - projection = { - kind: "weight", - trendKgPerWeek, - confidence: "low", - message: "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.", - }; - } - } - } - - const streaks = { - waterDays: streak((day) => day.drinkMl >= drinkGoal), - proteinDays: streak((day) => day.protein >= proteinGoal * 0.8), - activityDays: streak((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); - - return { - days: dayRows, - week: { - weekStart, - dailyGoal: calorieGoal, - baseBudget: calorieGoal * 7, - plannedBudget, - eatenCalories, - activityCalories, - availableCalories: plannedBudget + activityCalories - eatenCalories, - balance: plannedBudget + activityCalories - eatenCalories, - days: weekDayRows, - }, - averages, - streaks, - wins: wins.length ? wins : ["Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser."], - projection, - }; - }), - - generateCoachRecommendations: protectedProcedure - .input(z.object({ date: dateKeySchema })) - .mutation(async ({ ctx, input }) => { - const userId = ctx.user.id; - const [profile, goals, mealLogs, drinkLogs, activityLogs] = await Promise.all([ - ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), - ctx.db.query.mealLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), - ctx.db.query.drinkLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), - ctx.db.query.activityLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), - ]); - - const calories = mealLogs.reduce((sum, meal) => sum + (meal.calories ?? 0), 0); - const proteinG = mealLogs.reduce((sum, meal) => sum + Number(meal.proteinG ?? 0), 0); - const drinkMl = drinkLogs.reduce((sum, drink) => sum + (drink.amountMl ?? 0), 0); - const activityMinutes = activityLogs.reduce((sum, activity) => sum + (activity.minutes ?? 0), 0); - const activityCalories = activityLogs.reduce((sum, activity) => sum + (activity.calories ?? 0), 0); - - const signals = buildDailyCoachSignals({ - date: input.date, - calorieGoal: goals?.calories ?? 2000, - proteinGoal: goals?.proteinG ?? 150, - drinkGoalMl: goals?.drinkMl ?? 2500, - calories, - proteinG, - drinkMl, - activityMinutes, - activityCalories, - medicalNote: profile?.medicalNote ?? false, - }); - - await ctx.db.transaction(async (tx) => { - await tx - .delete(schema.coachRecommendations) - .where(and(eq(schema.coachRecommendations.userId, userId), eq(schema.coachRecommendations.source, "rule"))); - await tx.insert(schema.coachRecommendations).values(signals.map((signal) => ({ - id: id("coach"), - userId, - title: signal.title, - body: signal.body, - priority: signal.priority, - category: signal.category, - source: "rule", - validFrom: input.date, - validTo: input.date, - }))); - }); - - auditHealthMutation(ctx, "coach-recommendations.generate", { - date: input.date, - count: signals.length, - }); - return { recommendations: signals }; - }), - - dismissCoachRecommendation: protectedProcedure - .input(z.object({ id: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - await ctx.db - .update(schema.coachRecommendations) - .set({ dismissedAt: new Date() }) - .where(and(eq(schema.coachRecommendations.id, input.id), eq(schema.coachRecommendations.userId, ctx.user.id))); - auditHealthMutation(ctx, "coach-recommendation.dismiss", { recommendationId: input.id }); - return { ok: true }; - }), -}); - - diff --git a/packages/api/src/trpc/routers/health/community.ts b/packages/api/src/trpc/routers/health/community.ts new file mode 100644 index 0000000..44b7832 --- /dev/null +++ b/packages/api/src/trpc/routers/health/community.ts @@ -0,0 +1,448 @@ +import { schema } from "@hellth/db"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { buildDailyCoachSignals } from "../../../lib/health-coach"; +import { protectedProcedure, router } from "../../init"; +import { + addDays, + auditHealthMutation, + calcRecipeCalories, + clubActivityInput, + clubProfileInput, + dateKeySchema, + dateRange, + id, + numberString, + startOfWeek, +} from "./_shared"; + +export const communityRouter = router({ + clubProfile: protectedProcedure.query(async ({ ctx }) => { + const profile = await ctx.db.query.clubProfiles.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), + }); + return profile ?? { + userId: ctx.user.id, + state: "Bayern", + city: "Augsburg", + latitude: null, + longitude: null, + privacyMode: "state", + updatedAt: new Date(), + }; + }), + + upsertClubProfile: protectedProcedure.input(clubProfileInput).mutation(async ({ ctx, input }) => { + await ctx.db + .insert(schema.clubProfiles) + .values({ + userId: ctx.user.id, + state: input.state, + city: input.city || null, + latitude: input.latitude == null ? null : numberString(input.latitude), + longitude: input.longitude == null ? null : numberString(input.longitude), + privacyMode: input.privacyMode, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.clubProfiles.userId, + set: { + state: input.state, + city: input.city || null, + latitude: input.latitude == null ? null : numberString(input.latitude), + longitude: input.longitude == null ? null : numberString(input.longitude), + privacyMode: input.privacyMode, + updatedAt: new Date(), + }, + }); + auditHealthMutation(ctx, "club-profile.upsert", { state: input.state, privacyMode: input.privacyMode }); + return { ok: true }; + }), + + clubActivities: protectedProcedure + .input(z.object({ startsOn: dateKeySchema.optional(), endsOn: dateKeySchema.optional() }).optional()) + .query(async ({ ctx, input }) => { + const startsOn = input?.startsOn ?? addDays(new Date().toISOString().slice(0, 10), -7); + const endsOn = input?.endsOn ?? addDays(new Date().toISOString().slice(0, 10), 30); + const activities = await ctx.db.query.clubActivities.findMany({ + where: (row, { and: andWhere, gte, lte }) => + andWhere(gte(row.startsAt, new Date(`${startsOn}T00:00:00`)), lte(row.startsAt, new Date(`${endsOn}T23:59:59`))), + orderBy: (row, { asc }) => [asc(row.startsAt)], + }); + const participants = activities.length + ? await ctx.db.query.clubActivityParticipants.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.activityId, activities.map((activity) => activity.id)), + }) + : []; + const userIds = [...new Set([...activities.map((activity) => activity.hostUserId), ...participants.map((participant) => participant.userId)])]; + const users = userIds.length + ? await ctx.db.query.user.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds), + columns: { id: true, name: true, email: true }, + }) + : []; + const userById = new Map(users.map((user) => [user.id, user])); + const participantsByActivity = new Map(); + for (const participant of participants) { + const current = participantsByActivity.get(participant.activityId) ?? []; + current.push(participant); + participantsByActivity.set(participant.activityId, current); + } + return activities.map((activity) => { + const host = userById.get(activity.hostUserId); + return { + ...activity, + startsAt: activity.startsAt.toISOString(), + latitude: activity.latitude == null ? null : Number(activity.latitude), + longitude: activity.longitude == null ? null : Number(activity.longitude), + hostName: host?.name || host?.email || "Hellth User", + participants: (participantsByActivity.get(activity.id) ?? []).map((participant) => { + const participantUser = userById.get(participant.userId); + return { + ...participant, + displayName: participant.displayName || participantUser?.name || participantUser?.email || "Hellth User", + joinedAt: participant.joinedAt.toISOString(), + completedAt: participant.completedAt?.toISOString() ?? null, + }; + }), + }; + }); + }), + + createClubActivity: protectedProcedure.input(clubActivityInput).mutation(async ({ ctx, input }) => { + const activityId = id("club"); + await ctx.db.transaction(async (tx) => { + await tx.insert(schema.clubActivities).values({ + id: activityId, + hostUserId: ctx.user.id, + title: input.title, + category: input.category, + scope: input.scope, + state: input.state || null, + city: input.city || null, + latitude: input.latitude == null ? null : numberString(input.latitude), + longitude: input.longitude == null ? null : numberString(input.longitude), + locationLabel: input.locationLabel || null, + startsAt: new Date(input.startsAt), + durationMinutes: input.durationMinutes, + points: input.points, + note: input.note || null, + updatedAt: new Date(), + }); + await tx.insert(schema.clubActivityParticipants).values({ + activityId, + userId: ctx.user.id, + displayName: ctx.user.name || ctx.user.email || "Du", + joinedAt: new Date(), + }); + }); + auditHealthMutation(ctx, "club-activity.create", { activityId, scope: input.scope, category: input.category }); + return { id: activityId }; + }), + + joinClubActivity: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => { + const activity = await ctx.db.query.clubActivities.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!activity) throw new TRPCError({ code: "NOT_FOUND", message: "Aktivität wurde nicht gefunden." }); + await ctx.db.insert(schema.clubActivityParticipants).values({ + activityId: input.id, + userId: ctx.user.id, + displayName: ctx.user.name || ctx.user.email || "Du", + joinedAt: new Date(), + }).onConflictDoNothing(); + auditHealthMutation(ctx, "club-activity.join", { activityId: input.id }); + return { ok: true }; + }), + + completeClubActivity: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => { + const activity = await ctx.db.query.clubActivities.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!activity) throw new TRPCError({ code: "NOT_FOUND", message: "Aktivität wurde nicht gefunden." }); + await ctx.db.transaction(async (tx) => { + await tx.insert(schema.clubActivityParticipants).values({ + activityId: input.id, + userId: ctx.user.id, + displayName: ctx.user.name || ctx.user.email || "Du", + joinedAt: new Date(), + completedAt: new Date(), + }).onConflictDoUpdate({ + target: [schema.clubActivityParticipants.activityId, schema.clubActivityParticipants.userId], + set: { completedAt: new Date() }, + }); + await tx.insert(schema.activityLogs).values({ + id: id("activity-log"), + userId: ctx.user.id, + date: activity.startsAt.toISOString().slice(0, 10), + name: `Hellfireclub: ${activity.title}`, + minutes: activity.durationMinutes, + points: activity.points, + calories: Math.round(activity.durationMinutes * 4), + source: "system", + note: "Gemeinsam getrennt im Hellfireclub", + }); + }); + auditHealthMutation(ctx, "club-activity.complete", { activityId: input.id, points: activity.points }); + return { ok: true }; + }), + + wearableConnections: protectedProcedure.query(async ({ ctx }) => { + const rows = await ctx.db.query.wearableConnections.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), + }); + return rows.map((row) => ({ + id: row.id, + provider: row.provider, + providerUserId: row.providerUserId, + scopes: row.scopes, + expiresAt: row.expiresAt, + lastSyncAt: row.lastSyncAt, + syncEnabled: row.syncEnabled, + })); + }), + + statsSummary: protectedProcedure + .input(z.object({ referenceDate: dateKeySchema })) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id; + const days = dateRange(input.referenceDate, 14); + const startsOn = days[0]!; + const endsOn = days[days.length - 1]!; + const weekStart = startOfWeek(input.referenceDate); + const weekDays = dateRange(addDays(weekStart, 6), 7); + + const [profile, goals, mealLogs, drinkLogs, weightLogs, activityLogs, mealPlans, weeklyPlans] = await Promise.all([ + ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ctx.db.query.mealLogs.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), + }), + ctx.db.query.drinkLogs.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), + }), + ctx.db.query.weightLogs.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), + orderBy: (row, { asc }) => [asc(row.date)], + }), + ctx.db.query.activityLogs.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, startsOn), lte(row.date, endsOn)), + }), + ctx.db.query.mealPlans.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, weekStart), lte(row.date, addDays(weekStart, 6))), + }), + ctx.db.query.weeklyCaloriePlans.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, weekStart), lte(row.date, addDays(weekStart, 6))), + }), + ]); + + const recipeIds = [...new Set(mealPlans.map((plan) => plan.recipeId).filter(Boolean))] as string[]; + const recipes = recipeIds.length + ? await ctx.db.query.recipes.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, recipeIds) }) + : []; + const ingredients = recipeIds.length + ? await ctx.db.query.recipeIngredients.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, recipeIds) }) + : []; + const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))]; + const foods = foodIds.length + ? await ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) + : []; + + const recipeCalories = new Map(recipes.map((recipe) => [recipe.id, calcRecipeCalories(recipe, ingredients, foods)])); + const plannedCaloriesByDate = new Map(); + for (const plan of mealPlans) { + const explicitCalories = Number(plan.plannedCalories ?? 0); + const recipeCaloriesForPlan = plan.recipeId ? recipeCalories.get(plan.recipeId) ?? 0 : 0; + const kcal = explicitCalories > 0 ? explicitCalories : recipeCaloriesForPlan * Number(plan.portions ?? 1); + plannedCaloriesByDate.set(plan.date, (plannedCaloriesByDate.get(plan.date) ?? 0) + kcal); + } + + const dayRows = days.map((date) => { + const loggedMeals = mealLogs.filter((meal) => meal.date === date); + const loggedCalories = loggedMeals.reduce((sum, meal) => sum + meal.calories, 0); + return { + date, + calories: loggedCalories || (date >= input.referenceDate ? Math.round(plannedCaloriesByDate.get(date) ?? 0) : 0), + plannedCalories: Math.round(plannedCaloriesByDate.get(date) ?? 0), + activityCalories: activityLogs.filter((log) => log.date === date).reduce((sum, log) => sum + log.calories, 0), + protein: Math.round(loggedMeals.reduce((sum, meal) => sum + Number(meal.proteinG ?? 0), 0)), + drinkMl: drinkLogs.filter((drink) => drink.date === date).reduce((sum, drink) => sum + drink.amountMl, 0), + weightKg: (() => { + const weight = weightLogs.find((log) => log.date === date); + return weight ? Number(weight.weightKg) : undefined; + })(), + }; + }); + + const relevantDays = dayRows.filter((day) => day.date <= input.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 streak = (predicate: (day: typeof dayRows[number]) => boolean) => { + let count = 0; + for (const day of [...relevantDays].reverse()) { + if (!predicate(day)) break; + count += 1; + } + return count; + }; + const calorieGoal = goals?.calories ?? 2000; + const drinkGoal = goals?.drinkMl ?? 2500; + const proteinGoal = goals?.proteinG ?? 150; + const weeklyPlanByDate = new Map(weeklyPlans.map((plan) => [plan.date, plan])); + const weekDayRows = weekDays.map((date) => { + const plannedCalories = weeklyPlanByDate.get(date)?.plannedCalories ?? calorieGoal; + const eatenCalories = Math.round(plannedCaloriesByDate.get(date) ?? 0); + const activityCalories = activityLogs.filter((log) => log.date === date).reduce((sum, log) => sum + log.calories, 0); + return { + date, + plannedCalories, + eatenCalories, + activityCalories, + balance: plannedCalories + activityCalories - eatenCalories, + note: weeklyPlanByDate.get(date)?.note ?? undefined, + }; + }); + const plannedBudget = weekDayRows.reduce((sum, day) => sum + day.plannedCalories, 0); + const eatenCalories = weekDayRows.reduce((sum, day) => sum + day.eatenCalories, 0); + const activityCalories = weekDayRows.reduce((sum, day) => sum + day.activityCalories, 0); + + const weightEntries = dayRows.filter((day) => day.weightKg !== undefined); + let projection: { kind: "weight"; trendKgPerWeek: number; targetDate?: string; message: string; confidence: "low" | "medium" } | undefined; + if (weightEntries.length >= 3) { + const first = weightEntries[0]!; + const last = weightEntries[weightEntries.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) { + const trendKgPerWeek = Math.round(((last.weightKg! - first.weightKg!) / elapsedDays) * 7 * 10) / 10; + const targetWeight = profile?.targetWeightKg ? Number(profile.targetWeightKg) : goals?.weightKg ? Number(goals.weightKg) : undefined; + if (targetWeight && ((targetWeight < last.weightKg! && trendKgPerWeek < -0.05) || (targetWeight > last.weightKg! && trendKgPerWeek > 0.05))) { + const estimatedDays = Math.ceil(Math.abs(last.weightKg! - targetWeight) / Math.max(0.01, Math.abs(trendKgPerWeek / 7))); + projection = { + kind: "weight", + trendKgPerWeek, + targetDate: addDays(input.referenceDate, estimatedDays), + confidence: elapsedDays >= 14 && weightEntries.length >= 5 ? "medium" : "low", + message: `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`, + }; + } else { + projection = { + kind: "weight", + trendKgPerWeek, + confidence: "low", + message: "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.", + }; + } + } + } + + const streaks = { + waterDays: streak((day) => day.drinkMl >= drinkGoal), + proteinDays: streak((day) => day.protein >= proteinGoal * 0.8), + activityDays: streak((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); + + return { + days: dayRows, + week: { + weekStart, + dailyGoal: calorieGoal, + baseBudget: calorieGoal * 7, + plannedBudget, + eatenCalories, + activityCalories, + availableCalories: plannedBudget + activityCalories - eatenCalories, + balance: plannedBudget + activityCalories - eatenCalories, + days: weekDayRows, + }, + averages, + streaks, + wins: wins.length ? wins : ["Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser."], + projection, + }; + }), + + generateCoachRecommendations: protectedProcedure + .input(z.object({ date: dateKeySchema })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + const [profile, goals, mealLogs, drinkLogs, activityLogs] = await Promise.all([ + ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ctx.db.query.mealLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), + ctx.db.query.drinkLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), + ctx.db.query.activityLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), + ]); + + const calories = mealLogs.reduce((sum, meal) => sum + (meal.calories ?? 0), 0); + const proteinG = mealLogs.reduce((sum, meal) => sum + Number(meal.proteinG ?? 0), 0); + const drinkMl = drinkLogs.reduce((sum, drink) => sum + (drink.amountMl ?? 0), 0); + const activityMinutes = activityLogs.reduce((sum, activity) => sum + (activity.minutes ?? 0), 0); + const activityCalories = activityLogs.reduce((sum, activity) => sum + (activity.calories ?? 0), 0); + + const signals = buildDailyCoachSignals({ + date: input.date, + calorieGoal: goals?.calories ?? 2000, + proteinGoal: goals?.proteinG ?? 150, + drinkGoalMl: goals?.drinkMl ?? 2500, + calories, + proteinG, + drinkMl, + activityMinutes, + activityCalories, + medicalNote: profile?.medicalNote ?? false, + }); + + await ctx.db.transaction(async (tx) => { + await tx + .delete(schema.coachRecommendations) + .where(and(eq(schema.coachRecommendations.userId, userId), eq(schema.coachRecommendations.source, "rule"))); + await tx.insert(schema.coachRecommendations).values(signals.map((signal) => ({ + id: id("coach"), + userId, + title: signal.title, + body: signal.body, + priority: signal.priority, + category: signal.category, + source: "rule", + validFrom: input.date, + validTo: input.date, + }))); + }); + + auditHealthMutation(ctx, "coach-recommendations.generate", { + date: input.date, + count: signals.length, + }); + return { recommendations: signals }; + }), + + dismissCoachRecommendation: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(schema.coachRecommendations) + .set({ dismissedAt: new Date() }) + .where(and(eq(schema.coachRecommendations.id, input.id), eq(schema.coachRecommendations.userId, ctx.user.id))); + auditHealthMutation(ctx, "coach-recommendation.dismiss", { recommendationId: input.id }); + return { ok: true }; + }), +}); diff --git a/packages/api/src/trpc/routers/health/dashboard.ts b/packages/api/src/trpc/routers/health/dashboard.ts new file mode 100644 index 0000000..ac19f80 --- /dev/null +++ b/packages/api/src/trpc/routers/health/dashboard.ts @@ -0,0 +1,169 @@ +import { schema } from "@hellth/db"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { platformProcedure, protectedProcedure, router } from "../../init"; +import { + auditHealthMutation, + dateKeySchema, + getPointSettings, + goalsInput, + numberString, + pointSettingsInput, + profileInput, +} from "./_shared"; + +export const dashboardRouter = router({ + pointDefaults: protectedProcedure.query(async ({ ctx }) => getPointSettings(ctx.db)), + + pointSettings: platformProcedure.query(async ({ ctx }) => { + const settings = await getPointSettings(ctx.db); + const templates = await ctx.db.query.activityTemplates.findMany({ + where: (row, { isNull: rowIsNull }) => rowIsNull(row.ownerUserId), + orderBy: (row, { asc }) => [asc(row.category), asc(row.name)], + }); + return { + ...settings, + activityTemplates: templates, + }; + }), + + updatePointSettings: platformProcedure.input(pointSettingsInput).mutation(async ({ ctx, input }) => { + await ctx.db.transaction(async (tx) => { + await tx + .insert(schema.platformSettings) + .values({ + key: "hellth.points", + value: JSON.stringify({ + recipeSubmissionPoints: input.recipeSubmissionPoints, + clubActivityDefaultPoints: input.clubActivityDefaultPoints, + }), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.platformSettings.key, + set: { + value: JSON.stringify({ + recipeSubmissionPoints: input.recipeSubmissionPoints, + clubActivityDefaultPoints: input.clubActivityDefaultPoints, + }), + updatedAt: new Date(), + }, + }); + + for (const template of input.activityTemplates) { + await tx.update(schema.activityTemplates) + .set({ defaultPoints: template.defaultPoints }) + .where(and(eq(schema.activityTemplates.id, template.id), isNull(schema.activityTemplates.ownerUserId))); + } + }); + auditHealthMutation(ctx, "point-settings.update", { + recipeSubmissionPoints: input.recipeSubmissionPoints, + clubActivityDefaultPoints: input.clubActivityDefaultPoints, + templates: input.activityTemplates.length, + }); + return { ok: true }; + }), + + overview: protectedProcedure + .input(z.object({ date: dateKeySchema })) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id; + const [ + profile, + goals, + mealLogs, + drinkLogs, + weightLog, + mealPlans, + activityLogs, + coachRecommendations, + wearableConnections, + ] = await Promise.all([ + ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ctx.db.query.mealLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }), + ctx.db.query.drinkLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }), + ctx.db.query.weightLogs.findFirst({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), + ctx.db.query.mealPlans.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)) }), + ctx.db.query.activityLogs.findMany({ where: (row, { and: andWhere, eq: rowEq }) => andWhere(rowEq(row.userId, userId), rowEq(row.date, input.date)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)] }), + ctx.db.query.coachRecommendations.findMany({ where: (row, { and: andWhere, eq: rowEq, isNull: rowIsNull }) => andWhere(rowEq(row.userId, userId), rowIsNull(row.dismissedAt)), orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], limit: 5 }), + ctx.db.query.wearableConnections.findMany({ where: (row, { eq: rowEq }) => rowEq(row.userId, userId) }), + ]); + + return { + profile, + goals, + day: { + date: input.date, + mealLogs, + drinkLogs, + weightLog, + mealPlans, + activityLogs, + }, + coachRecommendations, + wearableConnections: wearableConnections.map((connection) => ({ + id: connection.id, + provider: connection.provider, + providerUserId: connection.providerUserId, + scopes: connection.scopes, + lastSyncAt: connection.lastSyncAt, + syncEnabled: connection.syncEnabled, + })), + }; + }), + + upsertProfile: protectedProcedure.input(profileInput).mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + await ctx.db + .insert(schema.healthProfiles) + .values({ + userId, + ...input, + heightCm: numberString(input.heightCm, 0), + currentWeightKg: numberString(input.currentWeightKg, 0), + targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, + completedAt: input.completed ? new Date() : null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.healthProfiles.userId, + set: { + ...input, + heightCm: numberString(input.heightCm, 0), + currentWeightKg: numberString(input.currentWeightKg, 0), + targetWeightKg: input.targetWeightKg ? numberString(input.targetWeightKg) : null, + completedAt: input.completed ? new Date() : null, + updatedAt: new Date(), + }, + }); + auditHealthMutation(ctx, "profile.upsert", { goal: input.goal, completed: input.completed }); + return { ok: true }; + }), + + upsertGoals: protectedProcedure.input(goalsInput).mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + await ctx.db + .insert(schema.healthGoals) + .values({ + userId, + ...input, + weightKg: input.weightKg ? numberString(input.weightKg) : null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.healthGoals.userId, + set: { + ...input, + weightKg: input.weightKg ? numberString(input.weightKg) : null, + updatedAt: new Date(), + }, + }); + auditHealthMutation(ctx, "goals.upsert", { + calories: input.calories, + proteinG: input.proteinG, + weeklyCalories: input.weeklyCalories ?? null, + }); + return { ok: true }; + }), +}); diff --git a/packages/api/src/trpc/routers/health/foods.ts b/packages/api/src/trpc/routers/health/foods.ts new file mode 100644 index 0000000..7025309 --- /dev/null +++ b/packages/api/src/trpc/routers/health/foods.ts @@ -0,0 +1,247 @@ +import { schema } from "@hellth/db"; +import { TRPCError } from "@trpc/server"; +import { and, desc, eq, isNull, or } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure, router } from "../../init"; +import { + auditHealthMutation, + averageNutritionSources, + foodInput, + foodPreferenceInput, + id, + normalizeOpenFoodFactsProduct, + numberString, + nutritionSourceInput, +} from "./_shared"; + +export const foodsRouter = router({ + listFoods: protectedProcedure + .input(z.object({ query: z.string().trim().max(120).optional().default("") })) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id; + const rows = await ctx.db.query.foods.findMany({ + where: or(isNull(schema.foods.ownerUserId), eq(schema.foods.ownerUserId, userId)), + orderBy: [desc(schema.foods.updatedAt)], + limit: input.query ? 200 : 100, + }); + const needle = input.query.toLowerCase(); + const filtered = needle + ? rows.filter((food) => [food.name, food.brand, food.category, food.barcode].filter(Boolean).some((value) => value!.toLowerCase().includes(needle))) + : rows; + const portions = filtered.length + ? await ctx.db.query.foodPortions.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.foodId, filtered.map((food) => food.id)), + }) + : []; + const nutritionSources = filtered.length + ? await ctx.db.query.foodNutritionSources.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.foodId, filtered.map((food) => food.id)), + }) + : []; + const preferences = filtered.length + ? await ctx.db.query.foodUserPreferences.findMany({ + where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) => + andWhere(rowEq(row.userId, userId), rowInArray(row.foodId, filtered.map((food) => food.id))), + }) + : []; + const portionsByFood = new Map(); + for (const portion of portions) { + const current = portionsByFood.get(portion.foodId) ?? []; + current.push(portion); + portionsByFood.set(portion.foodId, current); + } + const sourcesByFood = new Map(); + for (const source of nutritionSources) { + const current = sourcesByFood.get(source.foodId) ?? []; + current.push(source); + sourcesByFood.set(source.foodId, current); + } + const preferencesByFood = new Map(preferences.map((preference) => [preference.foodId, preference])); + return filtered.map((food) => ({ + ...food, + portions: portionsByFood.get(food.id) ?? [], + nutritionSources: sourcesByFood.get(food.id) ?? [], + userVote: preferencesByFood.get(food.id)?.vote ?? 0, + })); + }), + + setFoodPreference: protectedProcedure.input(foodPreferenceInput).mutation(async ({ ctx, input }) => { + const food = await ctx.db.query.foods.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.foodId), + }); + if (!food || (food.ownerUserId && food.ownerUserId !== ctx.user.id)) { + throw new TRPCError({ code: "NOT_FOUND", message: "Lebensmittel wurde nicht gefunden." }); + } + if (input.vote === 0) { + await ctx.db + .delete(schema.foodUserPreferences) + .where(and(eq(schema.foodUserPreferences.userId, ctx.user.id), eq(schema.foodUserPreferences.foodId, input.foodId))); + } else { + await ctx.db + .insert(schema.foodUserPreferences) + .values({ + userId: ctx.user.id, + foodId: input.foodId, + vote: input.vote, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [schema.foodUserPreferences.userId, schema.foodUserPreferences.foodId], + set: { + vote: input.vote, + updatedAt: new Date(), + }, + }); + } + auditHealthMutation(ctx, "food-preference.set", { foodId: input.foodId, vote: input.vote }); + return { ok: true }; + }), + + saveFood: protectedProcedure.input(foodInput).mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + const foodId = input.id ?? id("food"); + const existing = input.id + ? await ctx.db.query.foods.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) + : null; + + if (existing?.ownerUserId && existing.ownerUserId !== userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "Dieses Lebensmittel gehört einem anderen Benutzer." }); + } + + await ctx.db.transaction(async (tx) => { + await tx + .insert(schema.foods) + .values({ + id: foodId, + ownerUserId: existing?.ownerUserId ?? userId, + name: input.name, + brand: input.brand || null, + barcode: input.barcode || null, + category: input.category, + kcalPer100g: numberString(input.kcalPer100g), + proteinPer100g: numberString(input.proteinPer100g), + carbsPer100g: numberString(input.carbsPer100g), + fatPer100g: numberString(input.fatPer100g), + servingG: numberString(input.servingG, 100), + preferredUnit: input.preferredUnit, + densityGPerMl: input.densityGPerMl ? numberString(input.densityGPerMl) : null, + source: input.source, + availableEverywhere: input.availableEverywhere, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.foods.id, + set: { + name: input.name, + brand: input.brand || null, + barcode: input.barcode || null, + category: input.category, + kcalPer100g: numberString(input.kcalPer100g), + proteinPer100g: numberString(input.proteinPer100g), + carbsPer100g: numberString(input.carbsPer100g), + fatPer100g: numberString(input.fatPer100g), + servingG: numberString(input.servingG, 100), + preferredUnit: input.preferredUnit, + densityGPerMl: input.densityGPerMl ? numberString(input.densityGPerMl) : null, + source: input.source, + availableEverywhere: input.availableEverywhere, + updatedAt: new Date(), + }, + }); + + await tx.delete(schema.foodPortions).where(eq(schema.foodPortions.foodId, foodId)); + if (input.portions.length) { + await tx.insert(schema.foodPortions).values(input.portions.map((portion) => ({ + id: portion.id ?? id("portion"), + foodId, + label: portion.label, + unit: portion.unit, + amount: numberString(portion.amount, 1), + gramsEquivalent: numberString(portion.gramsEquivalent, 1), + }))); + } + }); + + auditHealthMutation(ctx, input.id ? "food.update" : "food.create", { + foodId, + name: input.name, + portions: input.portions.length, + }); + return { id: foodId }; + }), + + searchOpenFoodFacts: protectedProcedure + .input(z.object({ + query: z.string().trim().min(2).max(120), + pageSize: z.number().int().min(1).max(25).default(10), + })) + .query(async ({ input }) => { + const url = new URL("https://world.openfoodfacts.org/cgi/search.pl"); + url.searchParams.set("search_terms", input.query); + url.searchParams.set("search_simple", "1"); + url.searchParams.set("action", "process"); + url.searchParams.set("json", "1"); + url.searchParams.set("page_size", String(input.pageSize)); + url.searchParams.set("fields", "code,product_name,brands,categories,serving_quantity,nutriments"); + + const response = await fetch(url, { + headers: { + "User-Agent": "HellthHub/0.1 (local-home-lab)", + }, + }); + if (!response.ok) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Open Food Facts konnte nicht abgefragt werden." }); + } + const payload = await response.json() as { products?: Array[0]> }; + return (payload.products ?? []) + .map(normalizeOpenFoodFactsProduct) + .filter((product) => product.name && product.kcalPer100g >= 0); + }), + + addNutritionSource: protectedProcedure.input(nutritionSourceInput).mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.foods.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.foodId), + }); + if (!existing || (existing.ownerUserId && existing.ownerUserId !== ctx.user.id)) { + throw new TRPCError({ code: "NOT_FOUND", message: "Lebensmittel wurde nicht gefunden." }); + } + + await ctx.db.insert(schema.foodNutritionSources).values({ + id: id("nutrition-source"), + foodId: input.foodId, + provider: input.provider, + providerFoodId: input.providerFoodId || null, + name: input.name, + brand: input.brand || null, + url: input.url || null, + kcalPer100g: numberString(input.kcalPer100g), + proteinPer100g: numberString(input.proteinPer100g), + carbsPer100g: numberString(input.carbsPer100g), + fatPer100g: numberString(input.fatPer100g), + servingG: input.servingG ? numberString(input.servingG) : null, + confidence: input.confidence, + raw: input.raw, + updatedAt: new Date(), + }); + + const sources = await ctx.db.query.foodNutritionSources.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.foodId, input.foodId), + }); + const average = averageNutritionSources(sources); + await ctx.db.update(schema.foods).set({ + kcalPer100g: numberString(average.kcalPer100g), + proteinPer100g: numberString(average.proteinPer100g), + carbsPer100g: numberString(average.carbsPer100g), + fatPer100g: numberString(average.fatPer100g), + source: "community", + updatedAt: new Date(), + }).where(eq(schema.foods.id, input.foodId)); + + auditHealthMutation(ctx, "food-nutrition-source.add", { + foodId: input.foodId, + provider: input.provider, + sources: sources.length, + }); + return { ok: true, average, sourceCount: sources.length }; + }), +}); diff --git a/packages/api/src/trpc/routers/health/index.ts b/packages/api/src/trpc/routers/health/index.ts new file mode 100644 index 0000000..0933b8b --- /dev/null +++ b/packages/api/src/trpc/routers/health/index.ts @@ -0,0 +1,18 @@ +import { mergeRouters } from "../../init"; +import { communityRouter } from "./community"; +import { dashboardRouter } from "./dashboard"; +import { foodsRouter } from "./foods"; +import { recipesRouter } from "./recipes"; +import { storesRouter } from "./stores"; +import { trackingRouter } from "./tracking"; + +// Flach zusammengefuehrt, damit Frontend-Aufrufe weiterhin als +// `trpc.health.listFoods` etc. funktionieren (keine BREAKING CHANGE). +export const healthRouter = mergeRouters( + dashboardRouter, + foodsRouter, + recipesRouter, + storesRouter, + trackingRouter, + communityRouter, +); diff --git a/packages/api/src/trpc/routers/health/recipes.ts b/packages/api/src/trpc/routers/health/recipes.ts new file mode 100644 index 0000000..809f01c --- /dev/null +++ b/packages/api/src/trpc/routers/health/recipes.ts @@ -0,0 +1,423 @@ +import { schema } from "@hellth/db"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { platformProcedure, protectedProcedure, router } from "../../init"; +import { + auditHealthMutation, + classifyFoodsForDiet, + getPointSettings, + id, + numberString, + recipeInput, + recipeNutrition, + recipeRecommendationInput, + recipeSubmissionReviewInput, + recipeSubmissionStatusSchema, +} from "./_shared"; + +export const recipesRouter = router({ + listRecipes: protectedProcedure + .input(z.object({ query: z.string().trim().max(120).optional().default("") })) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id; + const recipes = await ctx.db.query.recipes.findMany({ + where: (row, { or: orWhere, eq: rowEq }) => orWhere(rowEq(row.ownerUserId, userId), rowEq(row.isPublic, true)), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.updatedAt)], + limit: input.query ? 200 : 100, + }); + const needle = input.query.toLowerCase(); + const filtered = needle + ? recipes.filter((recipe) => [recipe.name, recipe.notes].filter(Boolean).some((value) => value!.toLowerCase().includes(needle))) + : recipes; + const ingredients = await ctx.db.query.recipeIngredients.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, filtered.map((recipe) => recipe.id)), + orderBy: (row, { asc: rowAsc }) => [rowAsc(row.sortOrder)], + }); + const ingredientsByRecipe = new Map(); + for (const ingredient of ingredients) { + const current = ingredientsByRecipe.get(ingredient.recipeId) ?? []; + current.push(ingredient); + ingredientsByRecipe.set(ingredient.recipeId, current); + } + return filtered.map((recipe) => ({ + ...recipe, + ingredients: ingredientsByRecipe.get(recipe.id) ?? [], + })); + }), + + recipeRecommendations: protectedProcedure.input(recipeRecommendationInput).query(async ({ ctx, input }) => { + const userId = ctx.user.id; + const profile = await ctx.db.query.healthProfiles.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.userId, userId), + }); + const preferredMealPrepDays = input.mealPrepDays ?? profile?.preferredMealPrepDays ?? 0; + const cooksPerDay = input.cooksPerDay ?? profile?.cooksPerDay ?? 1; + const dietPreferences = new Set(profile?.dietPreferences?.length ? profile.dietPreferences : ["vegan", "vegetarian", "meat", "fish"]); + const meatFishFrequency = profile?.meatFishFrequency ?? "flexible"; + + const recipes = await ctx.db.query.recipes.findMany({ + where: (row, { or: orWhere, eq: rowEq }) => orWhere(rowEq(row.ownerUserId, userId), rowEq(row.isPublic, true)), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.updatedAt)], + limit: 250, + }); + const needle = input.query.toLowerCase(); + const filteredRecipes = needle + ? recipes.filter((recipe) => [recipe.name, recipe.notes].filter(Boolean).some((value) => value!.toLowerCase().includes(needle))) + : recipes; + if (!filteredRecipes.length) return []; + + const ingredients = await ctx.db.query.recipeIngredients.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, filteredRecipes.map((recipe) => recipe.id)), + orderBy: (row, { asc }) => [asc(row.sortOrder)], + }); + const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))]; + const [foods, preferences] = await Promise.all([ + foodIds.length ? ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) : [], + foodIds.length + ? ctx.db.query.foodUserPreferences.findMany({ + where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) => + andWhere(rowEq(row.userId, userId), rowInArray(row.foodId, foodIds)), + }) + : [], + ]); + const foodById = new Map(foods.map((food) => [food.id, food])); + const voteByFood = new Map(preferences.map((preference) => [preference.foodId, preference.vote])); + const ingredientsByRecipe = new Map(); + for (const ingredient of ingredients) { + const current = ingredientsByRecipe.get(ingredient.recipeId) ?? []; + current.push(ingredient); + ingredientsByRecipe.set(ingredient.recipeId, current); + } + + return filteredRecipes + .map((recipe) => { + const recipeIngredients = ingredientsByRecipe.get(recipe.id) ?? []; + const recipeFoods = recipeIngredients.map((ingredient) => foodById.get(ingredient.foodId)).filter(Boolean) as typeof foods; + const diet = classifyFoodsForDiet(recipeFoods); + const containsDislikedFood = recipeIngredients.some((ingredient) => voteByFood.get(ingredient.foodId) === -1); + const likedCount = recipeIngredients.filter((ingredient) => voteByFood.get(ingredient.foodId) === 1).length; + const blockedByDiet = + (diet.hasAnimal && !dietPreferences.has("vegetarian") && !dietPreferences.has("meat") && !dietPreferences.has("fish")) || + (diet.hasMeat && !dietPreferences.has("meat")) || + (diet.hasFish && !dietPreferences.has("fish")) || + (!diet.hasAnimal && !dietPreferences.has("vegan")); + if (blockedByDiet || containsDislikedFood) return null; + + const nutrition = recipeNutrition(recipe, recipeIngredients, foods); + const mealPrepFit = preferredMealPrepDays === 0 + ? recipe.mealPrepDays <= 1 ? 0 : 1 + : Math.abs(recipe.mealPrepDays - preferredMealPrepDays); + let score = 70; + score += likedCount * 12; + score += Math.max(0, 18 - mealPrepFit * 6); + score += recipe.minutes <= 25 ? 10 : recipe.minutes <= 45 ? 4 : -4; + score += nutrition.protein >= 25 ? 8 : nutrition.protein >= 15 ? 4 : 0; + score += nutrition.kcal <= 750 ? 6 : -4; + if (meatFishFrequency === "none" && (diet.hasMeat || diet.hasFish)) score -= 50; + if (meatFishFrequency === "once_per_day" && (diet.hasMeat || diet.hasFish)) score -= 8; + if (cooksPerDay === 1 && recipe.mealPrepDays >= 2) score += 8; + if (cooksPerDay >= 2 && recipe.mealPrepDays <= 1) score += 4; + + return { + ...recipe, + ingredients: recipeIngredients.map((ingredient) => ({ + ...ingredient, + food: foodById.get(ingredient.foodId) ?? null, + userVote: voteByFood.get(ingredient.foodId) ?? 0, + })), + nutrition, + tags: diet.tags, + score: Math.max(0, Math.round(score)), + reasons: [ + preferredMealPrepDays > 0 ? `passt zu Mealprep ${preferredMealPrepDays} Tage` : "passt zu frischer Tagesplanung", + cooksPerDay === 1 ? "für 1x Kochen am Tag priorisiert" : "für flexible Kochfenster priorisiert", + likedCount > 0 ? `${likedCount} bevorzugte Lebensmittel enthalten` : undefined, + nutrition.protein >= 25 ? "proteinreich" : undefined, + ].filter(Boolean) as string[], + }; + }) + .filter((recommendation): recommendation is Exclude => recommendation !== null) + .sort((a, b) => b!.score - a!.score) + .slice(0, input.limit); + }), + + saveRecipe: protectedProcedure.input(recipeInput).mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + const recipeId = input.id ?? id("recipe"); + const existing = input.id + ? await ctx.db.query.recipes.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) + : null; + + if (existing && existing.ownerUserId !== userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "Dieses Rezept gehört einem anderen Benutzer." }); + } + + await ctx.db.transaction(async (tx) => { + await tx + .insert(schema.recipes) + .values({ + id: recipeId, + ownerUserId: userId, + name: input.name, + portions: input.portions, + minutes: input.minutes, + notes: input.notes || null, + mealPrepDays: input.mealPrepDays, + isPublic: input.isPublic, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.recipes.id, + set: { + name: input.name, + portions: input.portions, + minutes: input.minutes, + notes: input.notes || null, + mealPrepDays: input.mealPrepDays, + isPublic: input.isPublic, + updatedAt: new Date(), + }, + }); + + await tx.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, recipeId)); + await tx.insert(schema.recipeIngredients).values(input.ingredients.map((ingredient, index) => ({ + id: ingredient.id ?? id("ingredient"), + recipeId, + foodId: ingredient.foodId, + amountG: numberString(ingredient.amountG), + displayAmount: ingredient.displayAmount ? numberString(ingredient.displayAmount) : null, + displayUnit: ingredient.displayUnit ?? null, + portionId: ingredient.portionId ?? null, + sortOrder: ingredient.sortOrder ?? index, + }))); + }); + + auditHealthMutation(ctx, input.id ? "recipe.update" : "recipe.create", { + recipeId, + ingredients: input.ingredients.length, + mealPrepDays: input.mealPrepDays, + }); + return { id: recipeId }; + }), + + submitRecipeSuggestion: protectedProcedure.input(recipeInput.omit({ id: true, isPublic: true })).mutation(async ({ ctx, input }) => { + const submissionId = id("recipe-submission"); + const pointSettings = await getPointSettings(ctx.db); + const recipeSubmissionPoints = pointSettings.recipeSubmissionPoints; + await ctx.db.transaction(async (tx) => { + await tx.insert(schema.recipeSubmissions).values({ + id: submissionId, + userId: ctx.user.id, + name: input.name, + portions: input.portions, + minutes: input.minutes, + notes: input.notes || null, + mealPrepDays: input.mealPrepDays, + status: "pending", + updatedAt: new Date(), + }); + await tx.insert(schema.recipeSubmissionIngredients).values(input.ingredients.map((ingredient, index) => ({ + id: ingredient.id ?? id("submission-ingredient"), + submissionId, + foodId: ingredient.foodId, + amountG: numberString(ingredient.amountG), + displayAmount: ingredient.displayAmount ? numberString(ingredient.displayAmount) : null, + displayUnit: ingredient.displayUnit ?? null, + portionId: ingredient.portionId ?? null, + sortOrder: ingredient.sortOrder ?? index, + }))); + await tx.insert(schema.activityLogs).values({ + id: id("activity-log"), + userId: ctx.user.id, + date: new Date().toISOString().slice(0, 10), + name: `Rezeptvorschlag: ${input.name}`, + minutes: 0, + points: recipeSubmissionPoints, + calories: 0, + source: "system", + externalId: `recipe-submission:${submissionId}`, + note: "Punkte für einen eingereichten Rezeptvorschlag", + }).onConflictDoNothing(); + }); + auditHealthMutation(ctx, "recipe-submission.create", { + submissionId, + ingredients: input.ingredients.length, + points: recipeSubmissionPoints, + }); + return { id: submissionId, points: recipeSubmissionPoints }; + }), + + myRecipeSubmissions: protectedProcedure.query(async ({ ctx }) => { + const submissions = await ctx.db.query.recipeSubmissions.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + limit: 50, + }); + const ingredients = submissions.length + ? await ctx.db.query.recipeSubmissionIngredients.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.submissionId, submissions.map((submission) => submission.id)), + orderBy: (row, { asc }) => [asc(row.sortOrder)], + }) + : []; + const ingredientsBySubmission = new Map(); + for (const ingredient of ingredients) { + const current = ingredientsBySubmission.get(ingredient.submissionId) ?? []; + current.push(ingredient); + ingredientsBySubmission.set(ingredient.submissionId, current); + } + return submissions.map((submission) => ({ + ...submission, + ingredients: ingredientsBySubmission.get(submission.id) ?? [], + })); + }), + + recipeSubmissionsForReview: platformProcedure + .input(z.object({ status: recipeSubmissionStatusSchema.optional().default("pending") })) + .query(async ({ ctx, input }) => { + const submissions = await ctx.db.query.recipeSubmissions.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.status, input.status), + orderBy: (row, { asc }) => [asc(row.createdAt)], + limit: 100, + }); + const ingredients = submissions.length + ? await ctx.db.query.recipeSubmissionIngredients.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.submissionId, submissions.map((submission) => submission.id)), + orderBy: (row, { asc }) => [asc(row.sortOrder)], + }) + : []; + const foodIds = [...new Set(ingredients.map((ingredient) => ingredient.foodId))]; + const foods = foodIds.length + ? await ctx.db.query.foods.findMany({ where: (row, { inArray: rowInArray }) => rowInArray(row.id, foodIds) }) + : []; + const foodById = new Map(foods.map((food) => [food.id, food])); + const ingredientsBySubmission = new Map(); + for (const ingredient of ingredients) { + const current = ingredientsBySubmission.get(ingredient.submissionId) ?? []; + current.push(ingredient); + ingredientsBySubmission.set(ingredient.submissionId, current); + } + return submissions.map((submission) => ({ + ...submission, + ingredients: (ingredientsBySubmission.get(submission.id) ?? []).map((ingredient) => ({ + ...ingredient, + food: foodById.get(ingredient.foodId) ?? null, + })), + })); + }), + + adminActivityFeed: platformProcedure.query(async ({ ctx }) => { + const [recipeSubmissions, storeChangeRequests] = await Promise.all([ + ctx.db.query.recipeSubmissions.findMany({ + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + limit: 50, + }), + ctx.db.query.storeChangeRequests.findMany({ + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + limit: 50, + }), + ]); + const userIds = [...new Set([ + ...recipeSubmissions.map((item) => item.userId), + ...storeChangeRequests.map((item) => item.userId), + ])]; + const users = userIds.length + ? await ctx.db.query.user.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds), + columns: { id: true, name: true, email: true }, + }) + : []; + const userById = new Map(users.map((user) => [user.id, user])); + const recipeItems = recipeSubmissions.map((submission) => { + const actor = userById.get(submission.userId); + return { + id: submission.id, + type: "recipe_submission" as const, + title: submission.name, + status: submission.status, + actorName: actor?.name || actor?.email || "Unbekannter Benutzer", + actorEmail: actor?.email ?? null, + createdAt: submission.createdAt.toISOString(), + href: "/rezepte/pruefung", + detail: `${submission.minutes} min · ${submission.portions} Portion(en)`, + }; + }); + const storeItems = storeChangeRequests.map((request) => { + const actor = userById.get(request.userId); + return { + id: request.id, + type: "store_change" as const, + title: request.title, + status: request.status, + actorName: actor?.name || actor?.email || "Unbekannter Benutzer", + actorEmail: actor?.email ?? null, + createdAt: request.createdAt.toISOString(), + href: "/maerkte", + detail: request.note || request.requestType, + }; + }); + return [...recipeItems, ...storeItems] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 80); + }), + + reviewRecipeSubmission: platformProcedure.input(recipeSubmissionReviewInput).mutation(async ({ ctx, input }) => { + const submission = await ctx.db.query.recipeSubmissions.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!submission) throw new TRPCError({ code: "NOT_FOUND", message: "Rezeptvorschlag wurde nicht gefunden." }); + if (submission.status !== "pending") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Rezeptvorschlag wurde bereits geprüft." }); + } + + let acceptedRecipeId: string | null = null; + await ctx.db.transaction(async (tx) => { + if (input.action === "accept") { + const ingredients = await tx.query.recipeSubmissionIngredients.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.submissionId, input.id), + orderBy: (row, { asc }) => [asc(row.sortOrder)], + }); + acceptedRecipeId = id("recipe"); + await tx.insert(schema.recipes).values({ + id: acceptedRecipeId, + ownerUserId: submission.userId, + name: submission.name, + portions: submission.portions, + minutes: submission.minutes, + notes: submission.notes, + mealPrepDays: submission.mealPrepDays, + isPublic: true, + updatedAt: new Date(), + }); + await tx.insert(schema.recipeIngredients).values(ingredients.map((ingredient, index) => ({ + id: id("ingredient"), + recipeId: acceptedRecipeId!, + foodId: ingredient.foodId, + amountG: ingredient.amountG, + displayAmount: ingredient.displayAmount, + displayUnit: ingredient.displayUnit, + portionId: ingredient.portionId, + sortOrder: ingredient.sortOrder ?? index, + }))); + } + + await tx.update(schema.recipeSubmissions) + .set({ + status: input.action === "accept" ? "accepted" : "rejected", + reviewNote: input.reviewNote || null, + reviewedBy: ctx.user.id, + reviewedAt: new Date(), + acceptedRecipeId, + updatedAt: new Date(), + }) + .where(eq(schema.recipeSubmissions.id, input.id)); + }); + + auditHealthMutation(ctx, "recipe-submission.review", { + submissionId: input.id, + action: input.action, + acceptedRecipeId, + }); + return { ok: true, recipeId: acceptedRecipeId }; + }), +}); diff --git a/packages/api/src/trpc/routers/health/stores.ts b/packages/api/src/trpc/routers/health/stores.ts new file mode 100644 index 0000000..0c7715d --- /dev/null +++ b/packages/api/src/trpc/routers/health/stores.ts @@ -0,0 +1,375 @@ +import { schema } from "@hellth/db"; +import { TRPCError } from "@trpc/server"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { z } from "zod"; +import { platformProcedure, protectedProcedure, router } from "../../init"; +import { + auditHealthMutation, + dateKeySchema, + id, + numberString, + storeChangeRequestInput, + storeChangeReviewInput, + storeFavoriteInput, + storeInput, +} from "./_shared"; + +export const storesRouter = router({ + listStores: protectedProcedure.query(async ({ ctx }) => { + const stores = await ctx.db.query.stores.findMany({ + where: or(isNull(schema.stores.ownerUserId), eq(schema.stores.ownerUserId, ctx.user.id)), + orderBy: (row, { desc: rowDesc, asc }) => [rowDesc(row.favorite), asc(row.name)], + }); + const preferences = stores.length + ? await ctx.db.query.storeUserPreferences.findMany({ + where: (row, { and: andWhere, eq: rowEq, inArray: rowInArray }) => + andWhere(rowEq(row.userId, ctx.user.id), rowInArray(row.storeId, stores.map((store) => store.id))), + }) + : []; + const departments = stores.length + ? await ctx.db.query.storeDepartments.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.storeId, stores.map((store) => store.id)), + orderBy: (row, { asc }) => [asc(row.sortOrder), asc(row.name)], + }) + : []; + const departmentsByStore = new Map(); + for (const department of departments) { + const current = departmentsByStore.get(department.storeId) ?? []; + current.push(department); + departmentsByStore.set(department.storeId, current); + } + const preferencesByStore = new Map(preferences.map((preference) => [preference.storeId, preference])); + return stores + .map((store) => ({ + ...store, + favorite: preferencesByStore.get(store.id)?.favorite ?? store.favorite, + latitude: store.latitude == null ? null : Number(store.latitude), + longitude: store.longitude == null ? null : Number(store.longitude), + departments: departmentsByStore.get(store.id) ?? [], + })) + .sort((a, b) => Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, "de-DE")); + }), + + setStoreFavorite: protectedProcedure.input(storeFavoriteInput).mutation(async ({ ctx, input }) => { + const store = await ctx.db.query.stores.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.storeId), + }); + if (!store || (store.ownerUserId && store.ownerUserId !== ctx.user.id)) { + throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." }); + } + await ctx.db + .insert(schema.storeUserPreferences) + .values({ + userId: ctx.user.id, + storeId: input.storeId, + favorite: input.favorite, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [schema.storeUserPreferences.userId, schema.storeUserPreferences.storeId], + set: { + favorite: input.favorite, + updatedAt: new Date(), + }, + }); + auditHealthMutation(ctx, "store.favorite", { storeId: input.storeId, favorite: input.favorite }); + return { ok: true }; + }), + + saveStore: protectedProcedure.input(storeInput).mutation(async ({ ctx, input }) => { + const storeId = input.id ?? id("store"); + const existing = input.id + ? await ctx.db.query.stores.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) + : null; + + if (existing && existing.ownerUserId !== ctx.user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "Globale Märkte und Märkte anderer Benutzer können nicht direkt geändert werden." }); + } + + await ctx.db.transaction(async (tx) => { + await tx + .insert(schema.stores) + .values({ + id: storeId, + ownerUserId: existing?.ownerUserId ?? ctx.user.id, + name: input.name, + address: input.address || null, + placeId: input.placeId || null, + latitude: input.latitude == null ? null : numberString(input.latitude), + longitude: input.longitude == null ? null : numberString(input.longitude), + favorite: input.favorite, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.stores.id, + set: { + name: input.name, + address: input.address || null, + placeId: input.placeId || null, + latitude: input.latitude == null ? null : numberString(input.latitude), + longitude: input.longitude == null ? null : numberString(input.longitude), + favorite: input.favorite, + updatedAt: new Date(), + }, + }); + + await tx.delete(schema.storeDepartments).where(eq(schema.storeDepartments.storeId, storeId)); + await tx.insert(schema.storeDepartments).values(input.departments.map((department) => ({ + id: department.id ?? id("department"), + storeId, + name: department.name, + sortOrder: department.sortOrder, + }))); + }); + + auditHealthMutation(ctx, input.id ? "store.update" : "store.create", { + storeId, + departments: input.departments.length, + }); + return { id: storeId }; + }), + + submitStoreChangeRequest: protectedProcedure.input(storeChangeRequestInput).mutation(async ({ ctx, input }) => { + const requestId = id("store-change"); + if (input.storeId) { + const store = await ctx.db.query.stores.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.storeId!), + }); + if (!store) throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." }); + } + await ctx.db.insert(schema.storeChangeRequests).values({ + id: requestId, + userId: ctx.user.id, + storeId: input.storeId || null, + requestType: input.requestType, + status: "pending", + title: input.title, + note: input.note || null, + proposedStore: input.store, + proposedDepartments: input.departments, + updatedAt: new Date(), + }); + auditHealthMutation(ctx, "store-change.submit", { + requestId, + storeId: input.storeId ?? null, + requestType: input.requestType, + }); + return { id: requestId }; + }), + + myStoreChangeRequests: protectedProcedure.query(async ({ ctx }) => { + return ctx.db.query.storeChangeRequests.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + limit: 50, + }); + }), + + reviewStoreChangeRequest: platformProcedure.input(storeChangeReviewInput).mutation(async ({ ctx, input }) => { + const request = await ctx.db.query.storeChangeRequests.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Änderungsantrag wurde nicht gefunden." }); + if (request.status !== "pending") throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Antrag wurde bereits geprüft." }); + + let acceptedStoreId: string | null = null; + await ctx.db.transaction(async (tx) => { + if (input.action === "accept") { + const proposedStore = request.proposedStore as { name?: string; address?: string | null; placeId?: string | null; latitude?: number | null; longitude?: number | null }; + const proposedDepartments = request.proposedDepartments as Array<{ id?: string; name?: string; sortOrder?: number }>; + acceptedStoreId = request.storeId ?? id("store-global"); + await tx.insert(schema.stores).values({ + id: acceptedStoreId, + ownerUserId: null, + name: proposedStore.name || request.title, + address: proposedStore.address || null, + placeId: proposedStore.placeId || null, + latitude: proposedStore.latitude == null ? null : numberString(proposedStore.latitude), + longitude: proposedStore.longitude == null ? null : numberString(proposedStore.longitude), + favorite: false, + updatedAt: new Date(), + }).onConflictDoUpdate({ + target: schema.stores.id, + set: { + ownerUserId: null, + name: proposedStore.name || request.title, + address: proposedStore.address || null, + placeId: proposedStore.placeId || null, + latitude: proposedStore.latitude == null ? null : numberString(proposedStore.latitude), + longitude: proposedStore.longitude == null ? null : numberString(proposedStore.longitude), + updatedAt: new Date(), + }, + }); + await tx.delete(schema.storeDepartments).where(eq(schema.storeDepartments.storeId, acceptedStoreId)); + await tx.insert(schema.storeDepartments).values(proposedDepartments.map((department, index) => ({ + id: department.id ?? id("department"), + storeId: acceptedStoreId!, + name: department.name || `Abteilung ${index + 1}`, + sortOrder: department.sortOrder ?? index + 1, + }))); + } + await tx.update(schema.storeChangeRequests) + .set({ + status: input.action === "accept" ? "accepted" : "rejected", + reviewedBy: ctx.user.id, + reviewedAt: new Date(), + reviewNote: input.reviewNote || null, + acceptedStoreId, + updatedAt: new Date(), + }) + .where(eq(schema.storeChangeRequests.id, input.id)); + }); + auditHealthMutation(ctx, "store-change.review", { requestId: input.id, action: input.action, acceptedStoreId }); + return { ok: true, storeId: acceptedStoreId }; + }), + + deleteStore: protectedProcedure.input(z.object({ id: z.string().min(1) })).mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.stores.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Markt wurde nicht gefunden." }); + if (existing.ownerUserId !== ctx.user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "Globale Märkte können nicht gelöscht werden." }); + } + await ctx.db.delete(schema.stores).where(eq(schema.stores.id, input.id)); + auditHealthMutation(ctx, "store.delete", { storeId: input.id }); + return { ok: true }; + }), + + generateShoppingList: protectedProcedure + .input(z.object({ + startsOn: dateKeySchema, + endsOn: dateKeySchema, + storeId: z.string().optional().nullable(), + name: z.string().trim().max(180).optional(), + })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + const plans = await ctx.db.query.mealPlans.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, userId), gte(row.date, input.startsOn), lte(row.date, input.endsOn)), + }); + const recipeIds = [...new Set(plans.map((plan) => plan.recipeId).filter(Boolean))] as string[]; + const ingredients = recipeIds.length + ? await ctx.db.query.recipeIngredients.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.recipeId, recipeIds), + }) + : []; + const foods = ingredients.length + ? await ctx.db.query.foods.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.id, [...new Set(ingredients.map((ingredient) => ingredient.foodId))]), + }) + : []; + const foodById = new Map(foods.map((food) => [food.id, food])); + const planPortionsByRecipeId = new Map(); + for (const plan of plans) { + if (!plan.recipeId) continue; + planPortionsByRecipeId.set( + plan.recipeId, + (planPortionsByRecipeId.get(plan.recipeId) ?? 0) + Number(plan.portions ?? 1), + ); + } + const amountByFood = new Map(); + for (const ingredient of ingredients) { + const food = foodById.get(ingredient.foodId); + const portions = planPortionsByRecipeId.get(ingredient.recipeId) ?? 1; + const current = amountByFood.get(ingredient.foodId) ?? { + foodId: ingredient.foodId, + name: food?.name ?? "Unbekanntes Lebensmittel", + category: food?.category ?? "Sonstiges", + amountG: 0, + }; + current.amountG += Number(ingredient.amountG ?? 0) * portions; + amountByFood.set(ingredient.foodId, current); + } + + const shoppingListId = id("shopping"); + await ctx.db.transaction(async (tx) => { + await tx.insert(schema.shoppingLists).values({ + id: shoppingListId, + userId, + storeId: input.storeId ?? null, + name: input.name || `Einkauf ${input.startsOn} bis ${input.endsOn}`, + startsOn: input.startsOn, + endsOn: input.endsOn, + }); + const items = [...amountByFood.values()]; + if (items.length) { + await tx.insert(schema.shoppingListItems).values(items.map((item, index) => ({ + id: id("shopping-item"), + shoppingListId, + foodId: item.foodId, + name: item.name, + category: item.category, + amountG: numberString(Math.round(item.amountG)), + purchaseAmount: `${Math.round(item.amountG)} g`, + checked: false, + sortOrder: index, + }))); + } + }); + auditHealthMutation(ctx, "shopping-list.generate", { + shoppingListId, + startsOn: input.startsOn, + endsOn: input.endsOn, + itemCount: amountByFood.size, + }); + return { id: shoppingListId, itemCount: amountByFood.size }; + }), + + shoppingLists: protectedProcedure + .input(z.object({ limit: z.number().int().min(1).max(20).default(5) }).optional()) + .query(async ({ ctx, input }) => { + const lists = await ctx.db.query.shoppingLists.findMany({ + where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + limit: input?.limit ?? 5, + }); + const items = lists.length + ? await ctx.db.query.shoppingListItems.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.shoppingListId, lists.map((list) => list.id)), + orderBy: (row, { asc }) => [asc(row.sortOrder), asc(row.name)], + }) + : []; + const itemsByList = new Map(); + for (const item of items) { + const current = itemsByList.get(item.shoppingListId) ?? []; + current.push(item); + itemsByList.set(item.shoppingListId, current); + } + return lists.map((list) => ({ ...list, items: itemsByList.get(list.id) ?? [] })); + }), + + updateShoppingListItem: protectedProcedure + .input(z.object({ + id: z.string().min(1), + checked: z.boolean().optional(), + purchaseAmount: z.string().trim().max(120).optional().nullable(), + })) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db + .select({ + id: schema.shoppingListItems.id, + listUserId: schema.shoppingLists.userId, + }) + .from(schema.shoppingListItems) + .innerJoin(schema.shoppingLists, eq(schema.shoppingListItems.shoppingListId, schema.shoppingLists.id)) + .where(eq(schema.shoppingListItems.id, input.id)) + .limit(1); + if (!existing[0] || existing[0].listUserId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Einkaufslisten-Eintrag wurde nicht gefunden." }); + } + await ctx.db + .update(schema.shoppingListItems) + .set({ + ...(input.checked !== undefined ? { checked: input.checked } : {}), + ...(input.purchaseAmount !== undefined ? { purchaseAmount: input.purchaseAmount || null } : {}), + }) + .where(eq(schema.shoppingListItems.id, input.id)); + auditHealthMutation(ctx, "shopping-list-item.update", { itemId: input.id }); + return { ok: true }; + }), +}); + +// Note: `and` is imported but currently not used by procedures here. +void and; diff --git a/packages/api/src/trpc/routers/health/tracking.ts b/packages/api/src/trpc/routers/health/tracking.ts new file mode 100644 index 0000000..73dad76 --- /dev/null +++ b/packages/api/src/trpc/routers/health/tracking.ts @@ -0,0 +1,462 @@ +import { schema } from "@hellth/db"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure, router } from "../../init"; +import { + addDays, + auditHealthMutation, + dateKeySchema, + id, + mealTypeSchema, + numberString, + startOfWeek, +} from "./_shared"; + +export const trackingRouter = router({ + addDrinkLog: protectedProcedure + .input(z.object({ date: dateKeySchema, amountMl: z.number().int().positive().max(5000) })) + .mutation(async ({ ctx, input }) => { + await ctx.db.insert(schema.drinkLogs).values({ + id: id("drink"), + userId: ctx.user.id, + date: input.date, + amountMl: input.amountMl, + }); + auditHealthMutation(ctx, "drink.add", { date: input.date, amountMl: input.amountMl }); + return { ok: true }; + }), + + upsertWeightLog: protectedProcedure + .input(z.object({ date: dateKeySchema, weightKg: z.number().positive().max(500), note: z.string().max(1000).optional().nullable() })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .insert(schema.weightLogs) + .values({ + id: id("weight"), + userId: ctx.user.id, + date: input.date, + weightKg: numberString(input.weightKg), + note: input.note || null, + }) + .onConflictDoUpdate({ + target: [schema.weightLogs.userId, schema.weightLogs.date], + set: { + weightKg: numberString(input.weightKg), + note: input.note || null, + createdAt: new Date(), + }, + }); + auditHealthMutation(ctx, "weight.upsert", { date: input.date, weightKg: input.weightKg }); + return { ok: true }; + }), + + weightHistory: protectedProcedure + .input(z.object({ + startsOn: dateKeySchema.optional(), + endsOn: dateKeySchema.optional(), + limit: z.number().int().min(1).max(365).default(90), + }).optional()) + .query(async ({ ctx, input }) => { + const today = new Date().toISOString().slice(0, 10); + const endsOn = input?.endsOn ?? today; + const startsOn = input?.startsOn ?? addDays(endsOn, -(input?.limit ?? 90) + 1); + const [profile, goals, rows] = await Promise.all([ + ctx.db.query.healthProfiles.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id) }), + ctx.db.query.healthGoals.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id) }), + ctx.db.query.weightLogs.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, startsOn), lte(row.date, endsOn)), + orderBy: (row, { asc }) => [asc(row.date)], + limit: input?.limit ?? 90, + }), + ]); + + const entries = rows.map((row) => ({ + id: row.id, + date: row.date, + weightKg: Number(row.weightKg), + note: row.note, + createdAt: row.createdAt, + })); + const first = entries[0]; + const latest = entries.at(-1); + const previous = entries.at(-2); + const targetWeightKg = profile?.targetWeightKg ? Number(profile.targetWeightKg) : goals?.weightKg ? Number(goals.weightKg) : null; + const deltaFromFirst = first && latest ? Math.round((latest.weightKg - first.weightKg) * 10) / 10 : 0; + const deltaFromPrevious = previous && latest ? Math.round((latest.weightKg - previous.weightKg) * 10) / 10 : 0; + + let trendKgPerWeek: number | null = null; + let targetDate: string | null = null; + let projectionMessage = "Noch zu wenig Daten für eine faire Prognose."; + let confidence: "none" | "low" | "medium" = "none"; + + if (entries.length >= 3 && first && latest) { + const elapsedDays = Math.max( + 1, + Math.round((new Date(`${latest.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000), + ); + if (elapsedDays >= 7) { + trendKgPerWeek = Math.round(((latest.weightKg - first.weightKg) / elapsedDays) * 7 * 10) / 10; + confidence = elapsedDays >= 14 && entries.length >= 5 ? "medium" : "low"; + if ( + targetWeightKg && + ((targetWeightKg < latest.weightKg && trendKgPerWeek < -0.05) || + (targetWeightKg > latest.weightKg && trendKgPerWeek > 0.05)) + ) { + const estimatedDays = Math.ceil(Math.abs(latest.weightKg - targetWeightKg) / Math.max(0.01, Math.abs(trendKgPerWeek / 7))); + targetDate = addDays(endsOn, estimatedDays); + projectionMessage = `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`; + } else { + projectionMessage = "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark."; + } + } + } + + return { + startsOn, + endsOn, + entries, + latest, + targetWeightKg, + deltaFromFirst, + deltaFromPrevious, + trendKgPerWeek, + targetDate, + projectionMessage, + confidence, + }; + }), + + deleteWeightLog: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.weightLogs.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!existing || existing.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Gewichtseintrag wurde nicht gefunden." }); + } + await ctx.db.delete(schema.weightLogs).where(eq(schema.weightLogs.id, input.id)); + auditHealthMutation(ctx, "weight.delete", { id: input.id, date: existing.date }); + return { ok: true }; + }), + + addMealLog: protectedProcedure + .input(z.object({ + date: dateKeySchema, + mealType: mealTypeSchema, + name: z.string().trim().min(1).max(180), + amountLabel: z.string().trim().max(120).optional().nullable(), + calories: z.number().int().min(0).max(10000), + proteinG: z.number().min(0).max(1000).default(0), + carbsG: z.number().min(0).max(1000).default(0), + fatG: z.number().min(0).max(1000).default(0), + foodId: z.string().optional().nullable(), + recipeId: z.string().optional().nullable(), + })) + .mutation(async ({ ctx, input }) => { + await ctx.db.insert(schema.mealLogs).values({ + id: id("meal"), + userId: ctx.user.id, + date: input.date, + mealType: input.mealType, + foodId: input.foodId || null, + recipeId: input.recipeId || null, + name: input.name, + amountLabel: input.amountLabel || null, + calories: input.calories, + proteinG: numberString(input.proteinG), + carbsG: numberString(input.carbsG), + fatG: numberString(input.fatG), + }); + auditHealthMutation(ctx, "meal.add", { date: input.date, mealType: input.mealType, calories: input.calories }); + return { ok: true }; + }), + + deleteMealLog: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.mealLogs.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!existing || existing.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Der Eintrag wurde nicht gefunden." }); + } + await ctx.db.delete(schema.mealLogs).where(eq(schema.mealLogs.id, input.id)); + auditHealthMutation(ctx, "meal.delete", { id: input.id, date: existing.date }); + return { ok: true }; + }), + + deleteDrinkLog: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.drinkLogs.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!existing || existing.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Der Eintrag wurde nicht gefunden." }); + } + await ctx.db.delete(schema.drinkLogs).where(eq(schema.drinkLogs.id, input.id)); + auditHealthMutation(ctx, "drink.delete", { id: input.id, date: existing.date }); + return { ok: true }; + }), + + upsertMealPlan: protectedProcedure + .input(z.object({ + id: z.string().optional(), + date: dateKeySchema, + mealType: mealTypeSchema, + recipeId: z.string().optional().nullable(), + name: z.string().trim().min(1).max(180), + portions: z.number().positive().max(100).default(1), + plannedCalories: z.number().int().min(0).max(20000).default(0), + note: z.string().max(1000).optional().nullable(), + })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user.id; + const planId = input.id ?? id("meal-plan"); + const existing = input.id + ? await ctx.db.query.mealPlans.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) + : null; + if (existing && existing.userId !== userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "Diese Planung gehört einem anderen Benutzer." }); + } + await ctx.db + .insert(schema.mealPlans) + .values({ + id: planId, + userId, + date: input.date, + mealType: input.mealType, + recipeId: input.recipeId || null, + name: input.name, + portions: numberString(input.portions), + plannedCalories: input.plannedCalories, + note: input.note || null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: schema.mealPlans.id, + set: { + date: input.date, + mealType: input.mealType, + recipeId: input.recipeId || null, + name: input.name, + portions: numberString(input.portions), + plannedCalories: input.plannedCalories, + note: input.note || null, + updatedAt: new Date(), + }, + }); + auditHealthMutation(ctx, input.id ? "meal-plan.update" : "meal-plan.create", { + planId, + date: input.date, + mealType: input.mealType, + }); + return { id: planId }; + }), + + deleteMealPlan: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.mealPlans.findFirst({ + where: (row, { eq: rowEq }) => rowEq(row.id, input.id), + }); + if (!existing || existing.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Die Planung wurde nicht gefunden." }); + } + await ctx.db.delete(schema.mealPlans).where(eq(schema.mealPlans.id, input.id)); + auditHealthMutation(ctx, "meal-plan.delete", { id: input.id, date: existing.date }); + return { ok: true }; + }), + + mealPlansRange: protectedProcedure + .input(z.object({ startsOn: dateKeySchema, endsOn: dateKeySchema })) + .query(async ({ ctx, input }) => { + return ctx.db.query.mealPlans.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, input.startsOn), lte(row.date, input.endsOn)), + orderBy: (row, { asc }) => [asc(row.date), asc(row.mealType), asc(row.createdAt)], + }); + }), + + upsertWeeklyCaloriePlan: protectedProcedure + .input(z.object({ date: dateKeySchema, plannedCalories: z.number().int().min(800).max(6000), note: z.string().max(500).optional().nullable() })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .insert(schema.weeklyCaloriePlans) + .values({ + userId: ctx.user.id, + date: input.date, + plannedCalories: input.plannedCalories, + note: input.note || null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [schema.weeklyCaloriePlans.userId, schema.weeklyCaloriePlans.date], + set: { + plannedCalories: input.plannedCalories, + note: input.note || null, + updatedAt: new Date(), + }, + }); + auditHealthMutation(ctx, "weekly-calorie-plan.upsert", { + date: input.date, + plannedCalories: input.plannedCalories, + }); + return { ok: true }; + }), + + addActivityLog: protectedProcedure + .input(z.object({ + date: dateKeySchema, + name: z.string().trim().min(1).max(180), + templateId: z.string().max(180).optional().nullable(), + minutes: z.number().int().min(1).max(1440), + points: z.number().int().min(0).max(10000), + calories: z.number().int().min(0).max(10000).default(0), + source: z.enum(["manual", "fitbit", "health_connect", "system"]).default("manual"), + externalId: z.string().max(180).optional().nullable(), + note: z.string().max(1000).optional().nullable(), + })) + .mutation(async ({ ctx, input }) => { + await ctx.db.insert(schema.activityLogs).values({ + id: id("activity-log"), + userId: ctx.user.id, + templateId: input.templateId || null, + date: input.date, + name: input.name, + minutes: input.minutes, + points: input.points, + calories: input.calories, + source: input.source, + externalId: input.externalId || null, + note: input.note || null, + }).onConflictDoNothing(); + auditHealthMutation(ctx, "activity.add", { + date: input.date, + source: input.source, + points: input.points, + calories: input.calories, + }); + return { ok: true }; + }), + + activityWeek: protectedProcedure + .input(z.object({ date: dateKeySchema })) + .query(async ({ ctx, input }) => { + const weekStart = startOfWeek(input.date); + const weekEnd = addDays(weekStart, 6); + const logs = await ctx.db.query.activityLogs.findMany({ + where: (row, { and: andWhere, eq: rowEq, gte, lte }) => + andWhere(rowEq(row.userId, ctx.user.id), gte(row.date, weekStart), lte(row.date, weekEnd)), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + }); + return { + weekStart, + weekEnd, + logs, + }; + }), + + clubLeaderboard: protectedProcedure + .input(z.object({ days: z.number().int().min(7).max(365).default(90) }).optional()) + .query(async ({ ctx, input }) => { + const startsOn = addDays(new Date().toISOString().slice(0, 10), -(input?.days ?? 90) + 1); + const logs = await ctx.db.query.activityLogs.findMany({ + where: (row, { gte }) => gte(row.date, startsOn), + orderBy: (row, { desc: rowDesc }) => [rowDesc(row.createdAt)], + }); + const userIds = [...new Set(logs.map((log) => log.userId))]; + const users = userIds.length + ? await ctx.db.query.user.findMany({ + where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds), + columns: { id: true, name: true, email: true }, + }) + : []; + const userById = new Map(users.map((user) => [user.id, user])); + const scores = new Map(); + for (const log of logs) { + if (log.points <= 0) continue; + const user = userById.get(log.userId); + const current = scores.get(log.userId) ?? { + userKey: log.userId, + name: user?.name || user?.email || "Hellth User", + points: 0, + completed: 0, + }; + scores.set(log.userId, { + ...current, + points: current.points + log.points, + completed: current.completed + 1, + }); + } + return [...scores.values()] + .sort((a, b) => b.points - a.points || b.completed - a.completed) + .slice(0, 20); + }), + + deleteActivityLog: protectedProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .delete(schema.activityLogs) + .where(and(eq(schema.activityLogs.id, input.id), eq(schema.activityLogs.userId, ctx.user.id))); + auditHealthMutation(ctx, "activity.delete", { activityLogId: input.id }); + return { ok: true }; + }), + + listActivityTemplates: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.user.id; + return ctx.db.query.activityTemplates.findMany({ + where: (row, { or: orWhere, eq: rowEq, isNull: rowIsNull }) => orWhere(rowIsNull(row.ownerUserId), rowEq(row.ownerUserId, userId)), + orderBy: (row, { asc: rowAsc }) => [rowAsc(row.name)], + }); + }), + + saveActivityTemplate: protectedProcedure + .input(z.object({ + id: z.string().optional(), + name: z.string().trim().min(1).max(180), + category: z.string().trim().min(1).max(80).default("habit"), + defaultMinutes: z.number().int().min(1).max(1440), + defaultPoints: z.number().int().min(0).max(10000), + defaultCalories: z.number().int().min(0).max(10000).default(0), + icon: z.string().trim().max(80).default("custom"), + })) + .mutation(async ({ ctx, input }) => { + const templateId = input.id ?? id("activity"); + const existing = input.id + ? await ctx.db.query.activityTemplates.findFirst({ where: (row, { eq: rowEq }) => rowEq(row.id, input.id!) }) + : null; + if (existing?.ownerUserId && existing.ownerUserId !== ctx.user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "Diese Aktivitätsvorlage gehört einem anderen Benutzer." }); + } + await ctx.db + .insert(schema.activityTemplates) + .values({ + id: templateId, + ownerUserId: existing?.ownerUserId ?? ctx.user.id, + name: input.name, + category: input.category, + defaultMinutes: input.defaultMinutes, + defaultPoints: input.defaultPoints, + defaultCalories: input.defaultCalories, + icon: input.icon, + }) + .onConflictDoUpdate({ + target: schema.activityTemplates.id, + set: { + name: input.name, + category: input.category, + defaultMinutes: input.defaultMinutes, + defaultPoints: input.defaultPoints, + defaultCalories: input.defaultCalories, + icon: input.icon, + }, + }); + auditHealthMutation(ctx, input.id ? "activity-template.update" : "activity-template.create", { templateId }); + return { id: templateId }; + }), +});