lots of stuff, don't truly remember
This commit is contained in:
63
internal/nutrition/handler.go
Normal file
63
internal/nutrition/handler.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package nutrition
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
service: NewService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTargets GET /api/protected/nutrition/targets
|
||||
func (h *Handler) GetTargets(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.service.GetTargets(claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to calculate targets")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, targets)
|
||||
}
|
||||
|
||||
// GetWeekly GET /api/protected/nutrition/weekly
|
||||
func (h *Handler) GetWeekly(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
weekly, err := h.service.GetWeeklyNutrition(claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to calculate weekly nutrition")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, weekly)
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
respondJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
290
internal/nutrition/service.go
Normal file
290
internal/nutrition/service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user