first commit
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user