Files
Core_Be/Helpers/CustomerProgressHelper.cs
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

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";
}
}