515 lines
21 KiB
C#
515 lines
21 KiB
C#
using Fitness_core_BE.Exceptions;
|
|
using Fitness_core_BE.Models;
|
|
using Fitness_core_BE.Models.Enums;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
|
|
namespace Fitness_core_BE.Helpers
|
|
{
|
|
public static class CustomerProgressHelper
|
|
{
|
|
public static CustomerTarget BuildCustomerTarget(
|
|
Guid cusId,
|
|
decimal currentWeight,
|
|
decimal currentHeight,
|
|
decimal? targetWeight,
|
|
string? goal,
|
|
decimal timePeriodMonths,
|
|
ProgressPrediction predictions,
|
|
CustomerTarget? latestTarget,
|
|
DateTime createdAt)
|
|
{
|
|
var timePeriod = timePeriodMonths.ToString("0.##", CultureInfo.InvariantCulture);
|
|
|
|
return new CustomerTarget
|
|
{
|
|
FKCusId = cusId,
|
|
CurrentWeight = currentWeight,
|
|
CurrentHeight = currentHeight,
|
|
TargetWeight = targetWeight,
|
|
Goal = goal,
|
|
TimePeriod = timePeriod,
|
|
PredictedNutritionsJson = JsonSerializer.Serialize(predictions.PredictedNutritions),
|
|
PredictedWeightsJson = JsonSerializer.Serialize(predictions.PredictedWeights),
|
|
IsChanged = latestTarget != null &&
|
|
(latestTarget.CurrentWeight != currentWeight ||
|
|
latestTarget.CurrentHeight != currentHeight ||
|
|
latestTarget.TargetWeight != targetWeight ||
|
|
latestTarget.Goal != goal ||
|
|
latestTarget.TimePeriod != timePeriod),
|
|
CreatedAt = createdAt,
|
|
IsActive = true
|
|
};
|
|
}
|
|
|
|
public static object BuildCustomerProgressResponse(CustomerTarget customerTarget)
|
|
{
|
|
return new
|
|
{
|
|
ProgressId = customerTarget.CustomerTargetId,
|
|
customerTarget.CustomerTargetId,
|
|
customerTarget.CurrentWeight,
|
|
customerTarget.CurrentHeight,
|
|
customerTarget.TargetWeight,
|
|
customerTarget.Goal,
|
|
customerTarget.TimePeriod,
|
|
customerTarget.CreatedAt,
|
|
customerTarget.FKCusId,
|
|
customerTarget.IsActive,
|
|
TargetIsChanged = customerTarget.IsChanged,
|
|
TargetCreatedAt = customerTarget.CreatedAt,
|
|
PredictedNutritions = DeserializePayload.DeserializeJson<Dictionary<string, string>>(customerTarget.PredictedNutritionsJson),
|
|
PredictedWeights = DeserializePayload.DeserializeJson<Dictionary<string, decimal>>(customerTarget.PredictedWeightsJson)
|
|
};
|
|
}
|
|
|
|
public static ProgressPrediction BuildProgressPredictions(
|
|
decimal weight,
|
|
decimal targetWeight,
|
|
decimal height,
|
|
string? goal,
|
|
decimal timePeriodMonths,
|
|
int age,
|
|
string? gender,
|
|
string? activityLevel)
|
|
{
|
|
ValidatePredictionInput(weight, targetWeight, height, timePeriodMonths, age);
|
|
|
|
// Normalize user choices before using them in scientific formulas.
|
|
var goalType = GetGoalType(goal);
|
|
var sex = GetSex(gender);
|
|
var activity = GetActivityLevel(activityLevel);
|
|
ValidateGoalDirection(goalType, weight, targetWeight);
|
|
|
|
// Convert the target period into weeks and calculate the requested weekly pace.
|
|
var weeksAvailable = (double)timePeriodMonths * 4.33;
|
|
var totalChange = goalType == ProgressGoalType.Maintain
|
|
? 0.0
|
|
: Math.Abs((double)(weight - targetWeight));
|
|
var requiredWeeklyChange = weeksAvailable > 0
|
|
? totalChange / weeksAvailable
|
|
: 0.0;
|
|
|
|
// BMR uses Mifflin-St Jeor; TDEE applies the selected activity multiplier.
|
|
var bmr = CalculateBmr(weight, height, age, sex);
|
|
var tdee = bmr * activity.Multiplier;
|
|
var dailyCalorieAdjustment = GetDailyCalorieAdjustment(goalType, requiredWeeklyChange);
|
|
var direction = GetDirection(goalType);
|
|
|
|
// Apply calorie deficit, surplus, or maintenance based on the goal.
|
|
var targetCalories = goalType switch
|
|
{
|
|
ProgressGoalType.Loss => tdee - dailyCalorieAdjustment,
|
|
ProgressGoalType.Gain => tdee + dailyCalorieAdjustment,
|
|
_ => tdee
|
|
};
|
|
|
|
// Estimate weekly weight change using the 7700 kcal per kg approximation.
|
|
var predictedWeeklyChange = dailyCalorieAdjustment <= 0
|
|
? 0.0
|
|
: (dailyCalorieAdjustment * 7.0) / 7700.0;
|
|
|
|
// Protein depends on goal; fat is 25% of calories; carbs use remaining calories.
|
|
var proteinPerKg = GetProteinFactor(goalType, goal);
|
|
var protein = Math.Round((double)weight * proteinPerKg, 2);
|
|
var fat = Math.Round((targetCalories * 0.25) / 9.0, 2);
|
|
var remainingCalories = targetCalories - (protein * 4.0 + fat * 9.0);
|
|
var carbs = Math.Round(Math.Max(remainingCalories / 4.0, 0.0), 2);
|
|
var bmi = Math.Round(CalculateBmi(weight, height), 2);
|
|
var difficulty = DetermineDifficulty(requiredWeeklyChange);
|
|
|
|
// Build a week-by-week projection and prevent values from passing the target weight.
|
|
var totalWeeks = Math.Max(1, (int)Math.Ceiling(weeksAvailable));
|
|
var predictedWeights = new Dictionary<string, decimal>();
|
|
|
|
for (var week = 0; week <= totalWeeks; week++)
|
|
{
|
|
var predictedWeight = (double)weight + (direction * predictedWeeklyChange * week);
|
|
predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight);
|
|
predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1);
|
|
}
|
|
|
|
var signedCalorieAdjustment = direction * dailyCalorieAdjustment;
|
|
|
|
// Keep output as strings because this JSON is stored and returned directly to clients.
|
|
var predictedNutritions = new Dictionary<string, string>
|
|
{
|
|
["calories"] = $"{FormatNumber(targetCalories)} kcal/day",
|
|
["targetCalories"] = $"{FormatNumber(targetCalories)} kcal/day",
|
|
["bmr"] = $"{FormatNumber(bmr)} kcal/day",
|
|
["tdee"] = $"{FormatNumber(tdee)} kcal/day",
|
|
["protein"] = $"{FormatNumber(protein)}g",
|
|
["fat"] = $"{FormatNumber(fat)}g",
|
|
["carbs"] = $"{FormatNumber(carbs)}g",
|
|
["bmi"] = FormatNumber(bmi),
|
|
["activityLevel"] = activity.Label,
|
|
["activityMultiplier"] = FormatNumber(activity.Multiplier),
|
|
["dailyCalorieChange"] = $"{FormatSignedNumber(signedCalorieAdjustment)} kcal/day",
|
|
["requiredWeeklyWeightChange"] = $"{FormatNumber(requiredWeeklyChange)} kg/week",
|
|
["predictedWeeklyWeightChange"] = $"{FormatNumber(predictedWeeklyChange)} kg/week",
|
|
["difficulty"] = difficulty,
|
|
["intensity"] = difficulty
|
|
};
|
|
|
|
return new ProgressPrediction
|
|
{
|
|
PredictedNutritions = predictedNutritions,
|
|
PredictedWeights = predictedWeights,
|
|
Intensity = difficulty
|
|
};
|
|
}
|
|
|
|
// Re-projects just the weight trajectory (not the nutrition targets, which need
|
|
// activityLevel — a one-time input never persisted on CustomerTarget) from the
|
|
// customer's latest logged weight to their existing target, over however much of
|
|
// the original plan window is left. Called whenever a weight update changes
|
|
// CurrentWeight, so the "goal" line reflects real progress instead of staying
|
|
// fixed to the weight recorded at onboarding.
|
|
public static Dictionary<string, decimal> RebuildPredictedWeights(
|
|
decimal currentWeight,
|
|
decimal targetWeight,
|
|
string? goal,
|
|
double remainingWeeks)
|
|
{
|
|
var goalType = GetGoalType(goal);
|
|
var direction = GetDirection(goalType);
|
|
|
|
var totalChange = goalType == ProgressGoalType.Maintain
|
|
? 0.0
|
|
: Math.Abs((double)(currentWeight - targetWeight));
|
|
var weeklyChange = remainingWeeks > 0 ? totalChange / remainingWeeks : 0.0;
|
|
|
|
var totalWeeks = Math.Max(1, (int)Math.Ceiling(remainingWeeks));
|
|
var predictedWeights = new Dictionary<string, decimal>();
|
|
for (var week = 0; week <= totalWeeks; week++)
|
|
{
|
|
var predictedWeight = (double)currentWeight + (direction * weeklyChange * week);
|
|
predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight);
|
|
predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1);
|
|
}
|
|
return predictedWeights;
|
|
}
|
|
|
|
public static int GetProgressPredictionAge(Dictionary<string, JsonElement> data, Customer customer, DateTime asOf)
|
|
{
|
|
var age = DataExtractor.GetNullableInt(data, "age");
|
|
|
|
if (age.HasValue)
|
|
{
|
|
if (age.Value <= 0)
|
|
throw new BadRequestException("Field 'age' must be greater than zero.");
|
|
|
|
return age.Value;
|
|
}
|
|
|
|
if (!customer.BoD.HasValue)
|
|
throw new BadRequestException("Field 'age' is required when customer date of birth is not available.");
|
|
|
|
var calculatedAge = CalculateAge(customer.BoD.Value, asOf);
|
|
if (calculatedAge <= 0)
|
|
throw new BadRequestException(
|
|
$"The date of birth on this profile ({customer.BoD.Value:yyyy-MM-dd}) is today or in " +
|
|
"the future, so an age can't be worked out. Update the date of birth on the profile, " +
|
|
"or send an explicit 'age' with this request.");
|
|
|
|
return calculatedAge;
|
|
}
|
|
|
|
public static string GetProgressPredictionGender(Dictionary<string, JsonElement> data, Customer customer)
|
|
{
|
|
var gender = DataExtractor.GetOptionalStringByKeys(data, "gender", "Gender") ?? customer.Gender;
|
|
if (!string.IsNullOrWhiteSpace(gender))
|
|
return gender;
|
|
|
|
throw new BadRequestException("Field 'gender' is required when customer gender is not available.");
|
|
}
|
|
|
|
public static int CalculateAge(DateOnly birthDate, DateTime asOf)
|
|
{
|
|
var today = DateOnly.FromDateTime(asOf);
|
|
var age = today.Year - birthDate.Year;
|
|
|
|
if (birthDate > today.AddYears(-age))
|
|
age--;
|
|
|
|
return age;
|
|
}
|
|
|
|
public static bool IsMaintainGoal(string goal)
|
|
{
|
|
var normalizedGoal = NormalizeText(goal);
|
|
return normalizedGoal is "maintain" or "maintain weight";
|
|
}
|
|
|
|
private static void ValidatePredictionInput(
|
|
decimal weight,
|
|
decimal targetWeight,
|
|
decimal height,
|
|
decimal timePeriodMonths,
|
|
int age)
|
|
{
|
|
if (weight <= 0)
|
|
throw new ArgumentException("Field 'currentWeight' must be greater than zero.");
|
|
|
|
if (targetWeight <= 0)
|
|
throw new ArgumentException("Field 'targetWeight' must be greater than zero.");
|
|
|
|
if (height <= 0)
|
|
throw new ArgumentException("Field 'currentHeight' must be greater than zero.");
|
|
|
|
if (timePeriodMonths <= 0)
|
|
throw new ArgumentException("Field 'timePeriod' must be greater than zero.");
|
|
|
|
if (age <= 0)
|
|
throw new ArgumentException("Field 'age' must be greater than zero.");
|
|
}
|
|
|
|
private static ProgressGoalType GetGoalType(string? goal)
|
|
{
|
|
var normalizedGoal = NormalizeText(goal);
|
|
|
|
return normalizedGoal switch
|
|
{
|
|
"lose weight" => ProgressGoalType.Loss,
|
|
"gain muscle" => ProgressGoalType.Gain,
|
|
"body recomposition" => ProgressGoalType.Maintain,
|
|
"strength" => ProgressGoalType.Strength,
|
|
"endurance" => ProgressGoalType.Endurance,
|
|
"athletic performance" => ProgressGoalType.AthleticPerformance,
|
|
"stay fit" => ProgressGoalType.StayFit,
|
|
"flexibility" => ProgressGoalType.StayFit,
|
|
"beginner" => ProgressGoalType.Beginner,
|
|
|
|
_ => throw new ArgumentException("Field 'goal' must be one of these")
|
|
};
|
|
}
|
|
|
|
private static Sex GetSex(string? gender)
|
|
{
|
|
var normalizedGender = NormalizeText(gender);
|
|
|
|
return normalizedGender switch
|
|
{
|
|
"male" or "m" => Sex.Male,
|
|
"female" or "f" => Sex.Female,
|
|
_ => throw new ArgumentException("Field 'gender' must be either 'Male' or 'Female' for BMR calculation.")
|
|
};
|
|
}
|
|
|
|
private static ActivityLevelInfo GetActivityLevel(string? activityLevel)
|
|
{
|
|
var normalizedActivityLevel = NormalizeText(activityLevel);
|
|
|
|
return normalizedActivityLevel switch
|
|
{
|
|
"low" => new ActivityLevelInfo("Lightly Active", 1.375),
|
|
"moderate" => new ActivityLevelInfo("Moderately Active", 1.55),
|
|
"high" => new ActivityLevelInfo("Very Active", 1.725),
|
|
_ => throw new ArgumentException("Field 'activityLevel' must be one of: Lightly Active, Moderately Active, Very Active")
|
|
};
|
|
}
|
|
|
|
private static void ValidateGoalDirection(ProgressGoalType goalType, decimal weight, decimal targetWeight)
|
|
{
|
|
if (goalType == ProgressGoalType.Loss && targetWeight > weight)
|
|
throw new ArgumentException("Field 'targetWeight' must be less than or equal to current weight for weight loss or fat loss goals.");
|
|
|
|
if (goalType == ProgressGoalType.Gain && targetWeight < weight)
|
|
throw new ArgumentException("Field 'targetWeight' must be greater than or equal to current weight for weight gain or muscle gain goals.");
|
|
}
|
|
|
|
private static double CalculateBmr(decimal weight, decimal height, int age, Sex sex)
|
|
{
|
|
var baseBmr = (10.0 * (double)weight) + (6.25 * (double)height) - (5.0 * age);
|
|
return sex == Sex.Male
|
|
? baseBmr + 5.0
|
|
: baseBmr - 161.0;
|
|
}
|
|
|
|
private static double CalculateBmi(decimal weight, decimal height)
|
|
{
|
|
var heightMeters = (double)height / 100.0;
|
|
return (double)weight / (heightMeters * heightMeters);
|
|
}
|
|
|
|
private static double GetDailyCalorieAdjustment(ProgressGoalType goalType, double requiredWeeklyChange)
|
|
{
|
|
var requestedDailyChange = (requiredWeeklyChange * 7700.0) / 7.0;
|
|
|
|
return goalType switch
|
|
{
|
|
ProgressGoalType.Loss => Math.Min(requestedDailyChange, 700.0),
|
|
ProgressGoalType.Gain => Math.Min(requestedDailyChange, 400.0),
|
|
_ => 0.0
|
|
};
|
|
}
|
|
|
|
private static double GetProteinFactor(ProgressGoalType goalType, string? goal)
|
|
{
|
|
var normalizedGoal = NormalizeText(goal);
|
|
|
|
if (normalizedGoal == "muscle gain")
|
|
return 2.0;
|
|
|
|
return goalType switch
|
|
{
|
|
ProgressGoalType.Loss => 2.0,
|
|
ProgressGoalType.Gain => 1.8,
|
|
_ => 1.6
|
|
};
|
|
}
|
|
|
|
private static double GetDirection(ProgressGoalType goalType)
|
|
{
|
|
return goalType switch
|
|
{
|
|
ProgressGoalType.Loss => -1.0,
|
|
ProgressGoalType.Gain => 1.0,
|
|
_ => 0.0
|
|
};
|
|
}
|
|
|
|
private static double KeepPredictionInsideTarget(ProgressGoalType goalType, double predictedWeight, double targetWeight)
|
|
{
|
|
return goalType switch
|
|
{
|
|
ProgressGoalType.Loss => Math.Max(predictedWeight, targetWeight),
|
|
ProgressGoalType.Gain => Math.Min(predictedWeight, targetWeight),
|
|
_ => predictedWeight
|
|
};
|
|
}
|
|
|
|
private static string DetermineDifficulty(double requiredWeeklyChange)
|
|
{
|
|
return requiredWeeklyChange switch
|
|
{
|
|
<= 0.5 => "Easy",
|
|
<= 0.75 => "Moderate",
|
|
<= 1.0 => "Hard",
|
|
_ => "Extreme / Unsafe"
|
|
};
|
|
}
|
|
|
|
private static string NormalizeText(string? value)
|
|
{
|
|
var normalized = (value ?? string.Empty)
|
|
.Trim()
|
|
.ToLowerInvariant()
|
|
.Replace("-", " ")
|
|
.Replace("_", " ");
|
|
|
|
return string.Join(' ', normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
|
}
|
|
|
|
private static string FormatNumber(double value)
|
|
{
|
|
return value.ToString("0.##", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static string FormatSignedNumber(double value)
|
|
{
|
|
var sign = value > 0 ? "+" : string.Empty;
|
|
return sign + FormatNumber(value);
|
|
}
|
|
|
|
private readonly record struct ActivityLevelInfo(string Label, double Multiplier);
|
|
|
|
public static string GetPredictedNutritionsJson(Dictionary<string, JsonElement> data)
|
|
{
|
|
var nutritions = data.TryGetValue("predictedNutritions", out var element)
|
|
? BuildNutritionDictionary(element)
|
|
: BuildNutritionDictionary(data);
|
|
|
|
return JsonSerializer.Serialize(nutritions);
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildNutritionDictionary(Dictionary<string, JsonElement> data)
|
|
{
|
|
return new Dictionary<string, string>
|
|
{
|
|
["calories"] = GetRequiredNutritionValueByKeys(data, "calories"),
|
|
["protein"] = GetRequiredNutritionValue(data, "protein"),
|
|
["fat"] = GetRequiredNutritionValue(data, "fat"),
|
|
["carbs"] = GetRequiredNutritionValue(data, "carbs"),
|
|
["intensity"] = GetRequiredNutritionValue(data, "intensity")
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildNutritionDictionary(JsonElement element)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
throw new BadRequestException("Field 'predictedNutritions' must be a JSON object.");
|
|
|
|
return new Dictionary<string, string>
|
|
{
|
|
["calories"] = GetRequiredNutritionValueByKeys(element, "calories"),
|
|
["protein"] = GetRequiredNutritionValue(element, "protein"),
|
|
["fat"] = GetRequiredNutritionValue(element, "fat"),
|
|
["carbs"] = GetRequiredNutritionValue(element, "carbs"),
|
|
["intensity"] = GetRequiredNutritionValue(element, "intensity")
|
|
};
|
|
}
|
|
|
|
private static string GetRequiredNutritionValueByKeys(Dictionary<string, JsonElement> data, params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
if (data.TryGetValue(key, out var element))
|
|
return ReadNutritionValue(element, key);
|
|
}
|
|
|
|
throw new BadRequestException($"Missing required field '{keys[0]}'.");
|
|
}
|
|
|
|
private static string GetRequiredNutritionValueByKeys(JsonElement source, params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
if (source.TryGetProperty(key, out var element))
|
|
return ReadNutritionValue(element, key);
|
|
}
|
|
|
|
throw new BadRequestException($"Missing required field '{keys[0]}'.");
|
|
}
|
|
|
|
private static string GetRequiredNutritionValue(Dictionary<string, JsonElement> data, string key)
|
|
{
|
|
if (!data.TryGetValue(key, out var element))
|
|
throw new BadRequestException($"Missing required field '{key}'.");
|
|
|
|
return ReadNutritionValue(element, key);
|
|
}
|
|
|
|
private static string GetRequiredNutritionValue(JsonElement source, string key)
|
|
{
|
|
if (!source.TryGetProperty(key, out var element))
|
|
throw new BadRequestException($"Missing required field '{key}'.");
|
|
|
|
return ReadNutritionValue(element, key);
|
|
}
|
|
|
|
private static string ReadNutritionValue(JsonElement element, string key)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.String)
|
|
{
|
|
var value = element.GetString();
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
return value;
|
|
}
|
|
|
|
if (element.ValueKind == JsonValueKind.Number)
|
|
return element.GetRawText();
|
|
|
|
throw new BadRequestException($"Field '{key}' must be a non-empty string or number.");
|
|
}
|
|
|
|
}
|
|
|
|
public sealed class ProgressPrediction
|
|
{
|
|
public Dictionary<string, string> PredictedNutritions { get; set; } = new();
|
|
public Dictionary<string, decimal> PredictedWeights { get; set; } = new();
|
|
public string Intensity { get; set; } = "Moderate";
|
|
}
|
|
}
|