291 lines
7.9 KiB
Go
291 lines
7.9 KiB
Go
package nutrition
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
|
|
"rideaware/internal/stats"
|
|
"rideaware/internal/user"
|
|
"rideaware/internal/workout"
|
|
"rideaware/pkg/database"
|
|
)
|
|
|
|
type Service struct {
|
|
userRepo *user.Repository
|
|
statsRepo *stats.Repository
|
|
workoutRepo *workout.Repository
|
|
}
|
|
|
|
func NewService() *Service {
|
|
return &Service{
|
|
userRepo: user.NewRepository(),
|
|
statsRepo: stats.NewRepository(),
|
|
workoutRepo: workout.NewRepository(),
|
|
}
|
|
}
|
|
|
|
// NutritionTargets contains calculated daily nutrition targets
|
|
type NutritionTargets struct {
|
|
BMR int `json:"bmr"`
|
|
TDEE int `json:"tdee"`
|
|
DailyCalories int `json:"daily_calories"`
|
|
GoalAdjustment int `json:"goal_adjustment"`
|
|
Protein int `json:"protein_g"`
|
|
Carbs int `json:"carbs_g"`
|
|
Fat int `json:"fat_g"`
|
|
NutritionGoal string `json:"nutrition_goal"`
|
|
DietaryPref string `json:"dietary_preference"`
|
|
CurrentWeight float64 `json:"current_weight"`
|
|
TargetWeight float64 `json:"target_weight"`
|
|
IsConfigured bool `json:"is_configured"`
|
|
}
|
|
|
|
// DailyNutrition represents one day's nutrition data
|
|
type DailyNutrition struct {
|
|
Date string `json:"date"`
|
|
BaseCalories int `json:"base_calories"`
|
|
WorkoutCalories int `json:"workout_calories"`
|
|
TotalTarget int `json:"total_target"`
|
|
Protein int `json:"protein_g"`
|
|
Carbs int `json:"carbs_g"`
|
|
Fat int `json:"fat_g"`
|
|
IsTrainingDay bool `json:"is_training_day"`
|
|
WorkoutTitle string `json:"workout_title,omitempty"`
|
|
}
|
|
|
|
// WeeklyNutrition contains a week of daily nutrition data
|
|
type WeeklyNutrition struct {
|
|
Days []DailyNutrition `json:"days"`
|
|
AvgCalories int `json:"avg_calories"`
|
|
AvgProtein int `json:"avg_protein_g"`
|
|
AvgCarbs int `json:"avg_carbs_g"`
|
|
AvgFat int `json:"avg_fat_g"`
|
|
TotalWorkoutCal int `json:"total_workout_calories"`
|
|
}
|
|
|
|
// CalculateBMR uses Mifflin-St Jeor equation
|
|
func CalculateBMR(weight, height float64, age int, gender string) int {
|
|
if weight <= 0 || height <= 0 || age <= 0 {
|
|
return 0
|
|
}
|
|
bmr := 10.0*weight + 6.25*height - 5.0*float64(age)
|
|
if gender == "female" {
|
|
bmr -= 161
|
|
} else {
|
|
bmr += 5
|
|
}
|
|
return int(math.Round(bmr))
|
|
}
|
|
|
|
// CalculateTDEE applies activity multiplier to BMR
|
|
func CalculateTDEE(bmr int, activityLevel string) int {
|
|
multipliers := map[string]float64{
|
|
"sedentary": 1.2,
|
|
"lightly_active": 1.375,
|
|
"active": 1.55,
|
|
"very_active": 1.725,
|
|
}
|
|
mult, ok := multipliers[activityLevel]
|
|
if !ok {
|
|
mult = 1.375 // default to lightly active
|
|
}
|
|
return int(math.Round(float64(bmr) * mult))
|
|
}
|
|
|
|
// goalAdjustment returns calorie adjustment for the goal
|
|
func goalAdjustment(goal string) int {
|
|
switch goal {
|
|
case "weight_loss":
|
|
return -500
|
|
case "performance":
|
|
return 300
|
|
default: // maintenance
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// macroSplit returns protein/carbs/fat percentages based on dietary preference
|
|
func macroSplit(pref string) (protPct, carbPct, fatPct float64) {
|
|
switch pref {
|
|
case "high_carb":
|
|
return 0.20, 0.60, 0.20
|
|
case "high_protein":
|
|
return 0.35, 0.40, 0.25
|
|
case "keto":
|
|
return 0.30, 0.10, 0.60
|
|
default: // balanced
|
|
return 0.25, 0.50, 0.25
|
|
}
|
|
}
|
|
|
|
// calculateMacros converts calories to grams of each macro
|
|
func calculateMacros(calories int, pref string) (protein, carbs, fat int) {
|
|
protPct, carbPct, fatPct := macroSplit(pref)
|
|
protein = int(math.Round(float64(calories) * protPct / 4.0)) // 4 cal/g
|
|
carbs = int(math.Round(float64(calories) * carbPct / 4.0)) // 4 cal/g
|
|
fat = int(math.Round(float64(calories) * fatPct / 9.0)) // 9 cal/g
|
|
return
|
|
}
|
|
|
|
// GetTargets calculates nutrition targets for a user
|
|
func (s *Service) GetTargets(userID uint) (*NutritionTargets, error) {
|
|
userObj, err := s.userRepo.GetUserByID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
profile := userObj.Profile
|
|
if profile == nil {
|
|
return &NutritionTargets{IsConfigured: false}, nil
|
|
}
|
|
|
|
// Check if nutrition is configured
|
|
if profile.Height <= 0 || profile.Age <= 0 || profile.Weight <= 0 || profile.Gender == "" {
|
|
return &NutritionTargets{
|
|
IsConfigured: false,
|
|
CurrentWeight: profile.Weight,
|
|
NutritionGoal: profile.NutritionGoal,
|
|
}, nil
|
|
}
|
|
|
|
bmr := CalculateBMR(profile.Weight, profile.Height, profile.Age, profile.Gender)
|
|
activityLevel := profile.ActivityLevel
|
|
if activityLevel == "" {
|
|
activityLevel = "lightly_active"
|
|
}
|
|
tdee := CalculateTDEE(bmr, activityLevel)
|
|
|
|
goal := profile.NutritionGoal
|
|
if goal == "" {
|
|
goal = "maintenance"
|
|
}
|
|
adj := goalAdjustment(goal)
|
|
dailyCal := tdee + adj
|
|
|
|
dietPref := profile.DietaryPref
|
|
if dietPref == "" {
|
|
dietPref = "balanced"
|
|
}
|
|
protein, carbs, fat := calculateMacros(dailyCal, dietPref)
|
|
|
|
return &NutritionTargets{
|
|
BMR: bmr,
|
|
TDEE: tdee,
|
|
DailyCalories: dailyCal,
|
|
GoalAdjustment: adj,
|
|
Protein: protein,
|
|
Carbs: carbs,
|
|
Fat: fat,
|
|
NutritionGoal: goal,
|
|
DietaryPref: dietPref,
|
|
CurrentWeight: profile.Weight,
|
|
TargetWeight: profile.TargetWeight,
|
|
IsConfigured: true,
|
|
}, nil
|
|
}
|
|
|
|
// GetWeeklyNutrition returns 7 days of nutrition data with workout adjustments
|
|
func (s *Service) GetWeeklyNutrition(userID uint) (*WeeklyNutrition, error) {
|
|
targets, err := s.GetTargets(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !targets.IsConfigured {
|
|
return &WeeklyNutrition{Days: []DailyNutrition{}}, nil
|
|
}
|
|
|
|
// Get workouts for the next 7 days
|
|
now := time.Now()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
endDate := today.AddDate(0, 0, 7)
|
|
|
|
workouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, today, endDate)
|
|
if err != nil {
|
|
workouts = []workout.Workout{}
|
|
}
|
|
|
|
// Also get past 7 days for completed workout calories
|
|
pastStart := today.AddDate(0, 0, -7)
|
|
pastWorkouts, err := s.workoutRepo.GetWorkoutsByDateRange(userID, pastStart, today)
|
|
if err != nil {
|
|
pastWorkouts = []workout.Workout{}
|
|
}
|
|
allWorkouts := append(pastWorkouts, workouts...)
|
|
|
|
// Build workout map by date
|
|
workoutMap := make(map[string][]workout.Workout)
|
|
for _, w := range allWorkouts {
|
|
dateStr := w.ScheduledDate.Format("2006-01-02")
|
|
workoutMap[dateStr] = append(workoutMap[dateStr], w)
|
|
}
|
|
|
|
// Get user FTP for calorie estimation
|
|
var ftp int
|
|
database.DB.Table("user_profiles").Select("ftp").Where("user_id = ?", userID).Scan(&ftp)
|
|
|
|
days := make([]DailyNutrition, 0, 7)
|
|
totalWorkoutCal := 0
|
|
|
|
for i := 0; i < 7; i++ {
|
|
date := today.AddDate(0, 0, i)
|
|
dateStr := date.Format("2006-01-02")
|
|
|
|
dayWorkouts := workoutMap[dateStr]
|
|
workoutCal := 0
|
|
isTraining := false
|
|
title := ""
|
|
|
|
for _, w := range dayWorkouts {
|
|
if w.CaloriesBurned > 0 {
|
|
workoutCal += w.CaloriesBurned
|
|
} else if w.Duration > 0 && ftp > 0 && w.AvgPower > 0 {
|
|
// Estimate: ~4 cal per kJ, kJ = watts * seconds / 1000
|
|
kj := float64(w.AvgPower) * float64(w.Duration) / 1000.0
|
|
workoutCal += int(kj * 4.0)
|
|
} else if w.Duration > 0 {
|
|
// Rough estimate: ~8 cal/min for cycling
|
|
workoutCal += (w.Duration / 60) * 8
|
|
}
|
|
isTraining = true
|
|
if title == "" {
|
|
title = w.Title
|
|
}
|
|
}
|
|
|
|
totalTarget := targets.DailyCalories + workoutCal
|
|
protein, carbs, fat := calculateMacros(totalTarget, targets.DietaryPref)
|
|
|
|
days = append(days, DailyNutrition{
|
|
Date: dateStr,
|
|
BaseCalories: targets.DailyCalories,
|
|
WorkoutCalories: workoutCal,
|
|
TotalTarget: totalTarget,
|
|
Protein: protein,
|
|
Carbs: carbs,
|
|
Fat: fat,
|
|
IsTrainingDay: isTraining,
|
|
WorkoutTitle: title,
|
|
})
|
|
|
|
totalWorkoutCal += workoutCal
|
|
}
|
|
|
|
// Calculate averages
|
|
avgCal, avgProt, avgCarb, avgFat := 0, 0, 0, 0
|
|
for _, d := range days {
|
|
avgCal += d.TotalTarget
|
|
avgProt += d.Protein
|
|
avgCarb += d.Carbs
|
|
avgFat += d.Fat
|
|
}
|
|
|
|
return &WeeklyNutrition{
|
|
Days: days,
|
|
AvgCalories: avgCal / 7,
|
|
AvgProtein: avgProt / 7,
|
|
AvgCarbs: avgCarb / 7,
|
|
AvgFat: avgFat / 7,
|
|
TotalWorkoutCal: totalWorkoutCal,
|
|
}, nil
|
|
}
|