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) <noreply@anthropic.com>
2026-05-28 01:09:53 +02:00
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 ,
2026-05-29 11:30:29 +02:00
extraCalories : planByDate.get ( date ) ? . extraCalories ? ? 0 ,
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) <noreply@anthropic.com>
2026-05-28 01:09:53 +02:00
eatenCalories ,
activityCalories ,
balance : plannedCalories + activityCalories - eatenCalories ,
note : planByDate.get ( date ) ? . note ,
} ;
} ) ;
const plannedBudget = days . reduce ( ( sum , day ) = > sum + day . plannedCalories , 0 ) ;
const eatenCalories = days . reduce ( ( sum , day ) = > sum + day . eatenCalories , 0 ) ;
const activityCalories = days . reduce ( ( sum , day ) = > sum + day . activityCalories , 0 ) ;
const availableCalories = plannedBudget + activityCalories - eatenCalories ;
return {
weekStart : dates [ 0 ] ! ,
dailyGoal ,
baseBudget : dailyGoal * 7 ,
plannedBudget ,
eatenCalories ,
activityCalories ,
availableCalories ,
balance : plannedBudget + activityCalories - eatenCalories ,
days ,
} ;
}
function plannedCaloriesForDate ( data : HellthData , date : string ) {
const recipeById = new Map ( data . recipes . map ( ( recipe ) = > [ recipe . id , recipe ] ) ) ;
return data . plannedMeals
. filter ( ( meal ) = > meal . date === date )
. reduce ( ( sum , meal ) = > {
const recipe = meal . recipeId ? recipeById . get ( meal . recipeId ) : undefined ;
if ( ! recipe ) return sum ;
return sum + calcRecipe ( recipe , data . foods , recipe . portions ) . kcal * meal . portions ;
} , 0 ) ;
}
function latestStreak < T extends HealthStatsDay > ( days : T [ ] , predicate : ( day : T ) = > boolean ) {
let streak = 0 ;
for ( const day of [ . . . days ] . reverse ( ) ) {
if ( ! predicate ( day ) ) break ;
streak += 1 ;
}
return streak ;
}
function buildWeightProjection (
data : HellthData ,
days : HealthStatsDay [ ] ,
referenceDate : string ,
) : HealthProjection | undefined {
const entries = days
. filter ( ( day ) = > day . weightKg !== undefined )
. map ( ( day ) = > ( { date : day.date , weightKg : day.weightKg ! } ) ) ;
if ( entries . length < 3 ) return undefined ;
const first = entries [ 0 ] ! ;
const last = entries [ entries . length - 1 ] ! ;
const elapsedDays = Math . max (
1 ,
Math . round ( ( new Date ( ` ${ last . date } T12:00:00 ` ) . getTime ( ) - new Date ( ` ${ first . date } T12:00:00 ` ) . getTime ( ) ) / 86 _400_000 ) ,
) ;
if ( elapsedDays < 7 ) return undefined ;
const trendKgPerWeek = Math . round ( ( ( last . weightKg - first . weightKg ) / elapsedDays ) * 7 * 10 ) / 10 ;
const targetWeight = data . healthProfile ? . targetWeightKg ? ? data . goals . weightKg ;
const wantsLowerWeight = targetWeight < last . weightKg ;
const movingTowardTarget = wantsLowerWeight ? trendKgPerWeek < - 0.05 : trendKgPerWeek > 0.05 ;
if ( ! movingTowardTarget ) {
return {
kind : "weight" ,
trendKgPerWeek ,
confidence : "low" ,
message : "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark." ,
} ;
}
const kgPerDay = Math . abs ( trendKgPerWeek / 7 ) ;
const remainingKg = Math . abs ( last . weightKg - targetWeight ) ;
const estimatedDays = Math . ceil ( remainingKg / Math . max ( 0.01 , kgPerDay ) ) ;
const targetDate = addDateDays ( referenceDate , estimatedDays ) ;
return {
kind : "weight" ,
trendKgPerWeek ,
targetDate ,
confidence : elapsedDays >= 14 && entries . length >= 5 ? "medium" : "low" ,
message : ` Aktueller Trend: ${ Math . abs ( trendKgPerWeek ) . toLocaleString ( "de-DE" ) } kg pro Woche in Richtung Ziel. ` ,
} ;
}
export function buildHealthStatsSummary (
data : HellthData ,
dayState : HellthDayStateSnapshot = { } ,
referenceDate = todayKey ( ) ,
) : HealthStatsSummary {
const days = Array . from ( { length : 14 } , ( _ , index ) = > addDateDays ( referenceDate , index - 13 ) ) . map ( ( date ) = > {
const snapshot = dayState . days ? . [ date ] ;
const loggedCalories = snapshot ? . foods ? . reduce ( ( sum , food ) = > sum + ( Number ( food . calories ) || 0 ) , 0 ) ? ? 0 ;
const plannedCalories = plannedCaloriesForDate ( data , date ) ;
const activityCalories = data . activityLogs
. filter ( ( log ) = > log . date === date )
. reduce ( ( sum , log ) = > sum + ( log . calories ? ? 0 ) , 0 ) ;
return {
date ,
calories : loggedCalories || ( date >= referenceDate ? plannedCalories : 0 ) ,
plannedCalories ,
activityCalories ,
protein : snapshot?.foods?.reduce ( ( sum , food ) = > sum + ( Number ( food . protein ) || 0 ) , 0 ) ? ? 0 ,
drinkMl : snapshot?.drinks?.reduce ( ( sum , drink ) = > sum + ( Number ( drink . amountMl ) || 0 ) , 0 ) ? ? 0 ,
weightKg : snapshot?.weightKg ,
} ;
} ) ;
const relevantDays = days . filter ( ( day ) = > day . date <= referenceDate ) ;
const divisor = Math . max ( 1 , relevantDays . length ) ;
const averages = {
calories : Math.round ( relevantDays . reduce ( ( sum , day ) = > sum + day . calories , 0 ) / divisor ) ,
protein : Math.round ( relevantDays . reduce ( ( sum , day ) = > sum + day . protein , 0 ) / divisor ) ,
drinkMl : Math.round ( relevantDays . reduce ( ( sum , day ) = > sum + day . drinkMl , 0 ) / divisor ) ,
activityCalories : Math.round ( relevantDays . reduce ( ( sum , day ) = > sum + day . activityCalories , 0 ) / divisor ) ,
} ;
const streaks = {
waterDays : latestStreak ( relevantDays , ( day ) = > day . drinkMl >= data . goals . drinkMl ) ,
proteinDays : latestStreak ( relevantDays , ( day ) = > day . protein >= data . goals . protein * 0.8 ) ,
activityDays : latestStreak ( relevantDays , ( day ) = > day . activityCalories > 0 ) ,
} ;
const wins = [
streaks . waterDays > 0 ? ` ${ streaks . waterDays } Tag(e) Trinkziel in Folge ` : undefined ,
streaks . proteinDays > 0 ? ` ${ streaks . proteinDays } Tag(e) Protein gut getroffen ` : undefined ,
streaks . activityDays > 0 ? ` ${ streaks . activityDays } Tag(e) mit Bewegung in Folge ` : undefined ,
averages . activityCalories > 0 ? ` Ø ${ averages . activityCalories } kcal Sportbonus pro Tag ` : undefined ,
] . filter ( Boolean ) as string [ ] ;
return {
days ,
week : buildWeeklyBudgetSummary ( data , referenceDate ) ,
averages ,
streaks ,
wins : wins.length ? wins : [ "Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser." ] ,
projection : buildWeightProjection ( data , days , referenceDate ) ,
} ;
}
export function redistributeWeeklyCalories (
data : HellthData ,
targetDate : string ,
extraCalories : number ,
referenceDate = todayKey ( ) ,
) {
const dates = weekDates ( referenceDate ) ;
const dailyGoal = data . goals . calories ;
const target = targetDate || referenceDate ;
const otherDates = dates . filter ( ( date ) = > date !== target ) ;
const savingPerOtherDay = otherDates . length ? Math . ceil ( extraCalories / otherDates . length ) : 0 ;
return dates . map ( ( date ) = > ( {
date ,
plannedCalories : date === target ? dailyGoal + extraCalories : Math.max ( 1200 , dailyGoal - savingPerOtherDay ) ,
note : date === target ? "bewusst höher geplant" : extraCalories > 0 ? "Ausgleichstag" : undefined ,
} ) ) ;
}
export function buildGoalsFromProfile ( profile : HealthProfile ) : HellthGoals {
const weight = Math . max ( 45 , profile . currentWeightKg || 80 ) ;
const height = Math . max ( 130 , profile . heightCm || 175 ) ;
const age = Math . max ( 18 , profile . age || 40 ) ;
const activityFactor =
profile . activityLevel === "active" ? 1.55 : profile.activityLevel === "moderate" ? 1.35 : 1.2 ;
// Neutral estimate without sex-specific assumptions. Good enough for an onboarding target,
// later refined by real weight trend and adherence.
const bmr = 10 * weight + 6.25 * height - 5 * age - 80 ;
const maintenance = Math . round ( bmr * activityFactor ) ;
const deficit =
profile . goal === "lose_weight"
? profile . pace === "ambitious"
? 700
: profile . pace === "normal"
? 500
: 300
: 0 ;
const floor = weight > 90 ? 1600 : 1400 ;
const calories = Math . max ( floor , maintenance - deficit ) ;
const protein = Math . round ( Math . min ( 220 , Math . max ( 90 , weight * ( profile . goal === "lose_weight" ? 1.6 : 1.3 ) ) ) ) ;
const fat = Math . round ( ( calories * 0.3 ) / 9 ) ;
const carbs = Math . round ( ( calories - protein * 4 - fat * 9 ) / 4 ) ;
return {
drinkMl : Math.round ( Math . min ( 3500 , Math . max ( 1800 , weight * 35 ) ) / 100 ) * 100 ,
calories ,
carbs : Math.max ( 80 , carbs ) ,
fat ,
protein ,
weightKg : profile.targetWeightKg || profile . currentWeightKg ,
fastingHours : profile.mealsPerDay <= 3 ? 12 : 10 ,
} ;
}
export function buildCoachRecommendations ( profile : HealthProfile , goals : HellthGoals ) : CoachRecommendation [ ] {
const recommendations : CoachRecommendation [ ] = [
{
id : "calorie-deficit" ,
title : profile.goal === "lose_weight" ? "Moderates Defizit halten" : "Stabile Routine aufbauen" ,
body :
profile . goal === "lose_weight"
? ` Starte mit ca. ${ goals . calories } kcal pro Tag. Prüfe nach 2 Wochen den Gewichtstrend statt einzelne Tageswerte zu bewerten. `
: ` Starte mit ca. ${ goals . calories } kcal pro Tag und bewerte Energie, Hunger und Gewichtstrend wöchentlich. ` ,
priority : "high" ,
} ,
{
id : "protein" ,
title : "Protein als Sattmacher planen" ,
body : ` Plane pro Tag etwa ${ goals . protein } g Protein ein. Praktisch: jede Hauptmahlzeit bekommt eine klare Proteinquelle. ` ,
priority : "high" ,
} ,
{
id : "water" ,
title : "Trinken sichtbar machen" ,
body : ` Dein Trinkziel liegt bei ${ goals . drinkMl } ml. Starte mit 500 ml bis Mittag und nutze die Wasserkarte als einfache Erinnerung. ` ,
priority : "medium" ,
} ,
] ;
if ( profile . biggestChallenge . trim ( ) ) {
recommendations . push ( {
id : "challenge" ,
title : "Haupt-Hürde zuerst entschärfen" ,
body : ` Du hast " ${ profile . biggestChallenge . trim ( ) } " als Hürde genannt. Plane dafür eine konkrete Standardlösung, z. B. ein schnelles Notfallgericht oder vorbereitete Snacks. ` ,
priority : "high" ,
} ) ;
}
if ( profile . cookingTime === "low" ) {
recommendations . push ( {
id : "simple-meals" ,
title : "Kurze Rezepte bevorzugen" ,
body : "Nutze Rezepte unter 20 Minuten und wiederhole gute Mahlzeiten bewusst. Abnehmen wird leichter, wenn nicht jede Mahlzeit neu entschieden werden muss." ,
priority : "medium" ,
} ) ;
}
if ( profile . budgetFocus ) {
recommendations . push ( {
id : "budget" ,
title : "Budgetfreundliche Basics" ,
body : "Haferflocken, Kartoffeln, Reis, Eier, Quark, TK-Gemüse und Hülsenfrüchte eignen sich gut für planbare, günstige Mahlzeiten." ,
priority : "low" ,
} ) ;
}
if ( profile . medicalNote ) {
recommendations . unshift ( {
id : "medical" ,
title : "Gesundheitliche Situation beachten" ,
body : "Bei Diagnosen, Medikamenten, Schwangerschaft oder Essstörungs-Vorgeschichte bitte Ziele mit Arzt oder Ernährungsfachkraft abstimmen. Hellth Hub ersetzt keine medizinische Beratung." ,
priority : "high" ,
} ) ;
}
return recommendations ;
}
export function isFoodAvailableInStore ( food : CatalogFood | undefined , storeId : string ) {
if ( ! food ) return true ;
if ( food . availableEverywhere ) return true ;
return Boolean ( food . storeIds ? . includes ( storeId ) ) ;
}
export function departmentForFood ( food : CatalogFood | undefined , store : Store ) {
const explicitId = food ? . storeDepartmentIds ? . [ store . id ] ? . [ 0 ] ;
if ( explicitId ) {
const explicit = store . departments . find ( ( department ) = > department . id === explicitId ) ;
if ( explicit ) return explicit ;
}
const category = food ? . category . toLowerCase ( ) ? ? "" ;
const match = store . departments . find ( ( department ) = > {
const name = department . name . toLowerCase ( ) ;
return (
( category . includes ( "obst" ) && name . includes ( "obst" ) ) ||
( category . includes ( "gemüse" ) && name . includes ( "gemüse" ) ) ||
( category . includes ( "brot" ) && name . includes ( "brot" ) ) ||
( category . includes ( "getreide" ) && ( name . includes ( "cerealien" ) || name . includes ( "trocken" ) ) ) ||
( category . includes ( "milch" ) && name . includes ( "milch" ) ) ||
( category . includes ( "protein" ) && ( name . includes ( "protein" ) || name . includes ( "fleisch" ) ) ) ||
( category . includes ( "öl" ) && ( name . includes ( "trocken" ) || name . includes ( "sonst" ) ) )
) ;
} ) ;
return match ? ? store . departments . find ( ( department ) = > department . name . toLowerCase ( ) . includes ( "sonst" ) ) ? ? store . departments . at ( - 1 ) ;
}
export function buildShoppingItemsFromPlan ( data : HellthData , dates? : string [ ] ) {
const dateSet = dates ? . length ? new Set ( dates ) : null ;
const foodById = new Map ( data . foods . map ( ( food ) = > [ food . id , food ] ) ) ;
const recipeById = new Map ( data . recipes . map ( ( recipe ) = > [ recipe . id , recipe ] ) ) ;
const previousByFoodId = new Map ( data . shoppingItems . filter ( ( item ) = > item . foodId ) . map ( ( item ) = > [ item . foodId ! , item ] ) ) ;
const previousManualItems = data . shoppingItems . filter ( ( item ) = > ! item . foodId ) ;
const generated = new Map < string , ShoppingItem > ( ) ;
for ( const plan of data . plannedMeals ) {
if ( dateSet && ! dateSet . has ( plan . date ) ) continue ;
const recipe = plan . recipeId ? recipeById . get ( plan . recipeId ) : undefined ;
if ( ! recipe ) continue ;
for ( const ingredient of recipe . ingredients ) {
const food = foodById . get ( ingredient . foodId ) ;
if ( ! food ) continue ;
const previous = previousByFoodId . get ( food . id ) ;
const current = generated . get ( food . id ) ;
const amountG = ingredient . amountG * plan . portions ;
generated . set ( food . id , {
id : current?.id ? ? previous ? . id ? ? uid ( "shop" ) ,
foodId : food.id ,
name : food.name ,
category : food.category ,
amountG : ( current ? . amountG ? ? 0 ) + amountG ,
purchaseAmount : current?.purchaseAmount ? ? previous ? . purchaseAmount ,
checked : current?.checked ? ? previous ? . checked ,
recipeIds : Array.from ( new Set ( [ . . . ( current ? . recipeIds ? ? [ ] ) , recipe . id ] ) ) ,
} ) ;
}
}
return [ . . . generated . values ( ) , . . . previousManualItems ] ;
}