lots of stuff, don't truly remember

This commit is contained in:
Blake Ridgway
2026-05-17 20:39:47 -05:00
parent 178ffb3425
commit dc4fe558b7
35 changed files with 3501 additions and 112 deletions

View File

@@ -0,0 +1,290 @@
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
}