Add new utility function to generate daily meal plans based on user's calorie targets and food categories. Include auto-generate button in meal planner UI with confirmation prompt. Add new Filipino food items to seed data for better meal variety. Fix database column references to use capitalized field names consistently.
461 lines
13 KiB
JavaScript
461 lines
13 KiB
JavaScript
const db = require('../models');
|
|
const { Op } = require('sequelize');
|
|
|
|
function calculateBMR(weightKg, heightCm, age, gender) {
|
|
// Calculate Basal Metabolic Rate using Mifflin-St Jeor Equation
|
|
let bmr;
|
|
if (gender.toLowerCase() === 'male') {
|
|
bmr = (10 * weightKg) + (6.25 * heightCm) - (5 * age) + 5;
|
|
} else {
|
|
// female
|
|
bmr = (10 * weightKg) + (6.25 * heightCm) - (5 * age) - 161;
|
|
}
|
|
return Math.round(bmr);
|
|
}
|
|
|
|
function calculateTDEE(bmr, activityLevel) {
|
|
// Calculate Total Daily Energy Expenditure
|
|
const multipliers = {
|
|
'sedentary': 1.2,
|
|
'light': 1.375,
|
|
'moderate': 1.55,
|
|
'active': 1.725,
|
|
'very_active': 1.9
|
|
};
|
|
|
|
const multiplier = multipliers[activityLevel] || 1.55;
|
|
return Math.round(bmr * multiplier);
|
|
}
|
|
|
|
function calculateMacroTargets(weightKg, goalType = 'recomp') {
|
|
// Calculate macro targets based on body weight and goal
|
|
let protein, carbs, fat;
|
|
|
|
if (goalType === 'muscle_gain') {
|
|
protein = weightKg * 2.4; // High protein for muscle building
|
|
carbs = weightKg * 3.5; // Higher carbs for energy
|
|
fat = weightKg * 1.0; // Moderate fat
|
|
} else if (goalType === 'weight_loss') {
|
|
protein = weightKg * 2.2; // High protein to preserve muscle
|
|
carbs = weightKg * 2.0; // Lower carbs for deficit
|
|
fat = weightKg * 0.8; // Lower fat
|
|
} else {
|
|
// recomp (body recomposition)
|
|
protein = weightKg * 2.2; // High protein
|
|
carbs = weightKg * 2.5; // Moderate carbs
|
|
fat = weightKg * 0.9; // Moderate fat
|
|
}
|
|
|
|
return {
|
|
protein_g: Math.round(protein),
|
|
carbs_g: Math.round(carbs),
|
|
fat_g: Math.round(fat)
|
|
};
|
|
}
|
|
|
|
async function calculateDailyTotals(userId, targetDate = null) {
|
|
// Calculate total nutrition consumed for a given date
|
|
if (!targetDate) {
|
|
targetDate = new Date();
|
|
}
|
|
|
|
// Ensure date is in YYYY-MM-DD format if it's a string, or create date object
|
|
const dateStr = targetDate instanceof Date ? targetDate.toISOString().split('T')[0] : targetDate;
|
|
|
|
// Get all meals for the date
|
|
const meals = await db.Meal.findAll({
|
|
where: {
|
|
UserId: userId,
|
|
date: dateStr
|
|
},
|
|
include: [{
|
|
model: db.MealFood,
|
|
include: [db.FoodItem]
|
|
}]
|
|
});
|
|
|
|
const totals = {
|
|
calories: 0,
|
|
protein: 0,
|
|
carbs: 0,
|
|
fat: 0,
|
|
meals: []
|
|
};
|
|
|
|
for (const meal of meals) {
|
|
const mealTotals = {
|
|
calories: 0,
|
|
protein: 0,
|
|
carbs: 0,
|
|
fat: 0
|
|
};
|
|
|
|
const mealFoods = [];
|
|
|
|
for (const mf of meal.MealFoods) {
|
|
// Calculate nutrition for this food item based on quantity
|
|
// If calculated values exist in MealFood, use them, otherwise calculate
|
|
const quantity = mf.quantity || 1.0;
|
|
const food = mf.FoodItem;
|
|
|
|
const calories = mf.calories_consumed || (food.calories * quantity);
|
|
const protein = mf.protein_consumed || (food.protein_g * quantity);
|
|
const carbs = mf.carbs_consumed || (food.carbs_g * quantity);
|
|
const fat = mf.fat_consumed || (food.fat_g * quantity);
|
|
|
|
mealTotals.calories += calories;
|
|
mealTotals.protein += protein;
|
|
mealTotals.carbs += carbs;
|
|
mealTotals.fat += fat;
|
|
|
|
mealFoods.push({
|
|
name: food.name,
|
|
quantity: quantity,
|
|
calories: calories
|
|
});
|
|
}
|
|
|
|
totals.calories += mealTotals.calories;
|
|
totals.protein += mealTotals.protein;
|
|
totals.carbs += mealTotals.carbs;
|
|
totals.fat += mealTotals.fat;
|
|
|
|
totals.meals.push({
|
|
id: meal.id,
|
|
type: meal.meal_type,
|
|
time: meal.time ? meal.time.substring(0, 5) : null,
|
|
totals: mealTotals,
|
|
foods: mealFoods
|
|
});
|
|
}
|
|
|
|
return totals;
|
|
}
|
|
|
|
async function calculateWaterTotal(userId, targetDate = null) {
|
|
// Calculate total water intake for a given date
|
|
if (!targetDate) {
|
|
targetDate = new Date();
|
|
}
|
|
|
|
const dateStr = targetDate instanceof Date ? targetDate.toISOString().split('T')[0] : targetDate;
|
|
|
|
const waterLogs = await db.WaterLog.findAll({
|
|
where: {
|
|
UserId: userId,
|
|
date: dateStr
|
|
}
|
|
});
|
|
|
|
const total = waterLogs.reduce((sum, log) => sum + log.amount_ml, 0);
|
|
|
|
return {
|
|
total_ml: total,
|
|
logs: waterLogs.map(log => ({
|
|
id: log.id,
|
|
amount_ml: log.amount_ml,
|
|
time: log.time ? log.time.substring(0, 5) : null
|
|
}))
|
|
};
|
|
}
|
|
|
|
async function getWeightTrend(userId, days = 7) {
|
|
// Get weight trend for the past N days
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(endDate.getDate() - (days - 1));
|
|
|
|
const startDateStr = startDate.toISOString().split('T')[0];
|
|
const endDateStr = endDate.toISOString().split('T')[0];
|
|
|
|
const weightLogs = await db.WeightLog.findAll({
|
|
where: {
|
|
UserId: userId,
|
|
date: {
|
|
[Op.between]: [startDateStr, endDateStr]
|
|
}
|
|
},
|
|
order: [['date', 'ASC']]
|
|
});
|
|
|
|
return weightLogs.map(log => ({
|
|
date: log.date,
|
|
weight_kg: log.weight_kg
|
|
}));
|
|
}
|
|
|
|
async function getCalorieTrend(userId, days = 7) {
|
|
// Get calorie intake trend for the past N days
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(endDate.getDate() - (days - 1));
|
|
|
|
const trend = [];
|
|
let currentDate = new Date(startDate);
|
|
|
|
while (currentDate <= endDate) {
|
|
const dateStr = currentDate.toISOString().split('T')[0];
|
|
const totals = await calculateDailyTotals(userId, dateStr);
|
|
|
|
trend.push({
|
|
date: dateStr,
|
|
calories: Math.round(totals.calories),
|
|
protein: Math.round(totals.protein),
|
|
carbs: Math.round(totals.carbs),
|
|
fat: Math.round(totals.fat)
|
|
});
|
|
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
}
|
|
|
|
return trend;
|
|
}
|
|
|
|
async function updateDailySummary(userId, targetDate = null) {
|
|
// Update or create daily summary for a user
|
|
if (!targetDate) {
|
|
targetDate = new Date();
|
|
}
|
|
|
|
const dateStr = targetDate instanceof Date ? targetDate.toISOString().split('T')[0] : targetDate;
|
|
|
|
// Calculate totals
|
|
const nutrition = await calculateDailyTotals(userId, dateStr);
|
|
const water = await calculateWaterTotal(userId, dateStr);
|
|
|
|
// Get weight for the day
|
|
const weightLog = await db.WeightLog.findOne({
|
|
where: {
|
|
UserId: userId,
|
|
date: dateStr
|
|
}
|
|
});
|
|
const weight = weightLog ? weightLog.weight_kg : null;
|
|
|
|
// Get user's calorie target
|
|
const user = await db.User.findByPk(userId);
|
|
const targetCalories = user ? user.target_daily_calories : 2000;
|
|
|
|
// Find or create summary
|
|
let summary = await db.DailySummary.findOne({
|
|
where: {
|
|
UserId: userId,
|
|
date: dateStr
|
|
}
|
|
});
|
|
|
|
if (!summary) {
|
|
summary = await db.DailySummary.create({
|
|
UserId: userId,
|
|
date: dateStr
|
|
});
|
|
}
|
|
|
|
// Update values
|
|
summary.total_calories = nutrition.calories;
|
|
summary.total_protein_g = nutrition.protein;
|
|
summary.total_carbs_g = nutrition.carbs;
|
|
summary.total_fat_g = nutrition.fat;
|
|
summary.total_water_ml = water.total_ml;
|
|
summary.calories_remaining = targetCalories - nutrition.calories;
|
|
summary.weight_kg = weight;
|
|
|
|
try {
|
|
await summary.save();
|
|
return summary;
|
|
} catch (e) {
|
|
console.error(`Error updating daily summary: ${e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getMacroPercentages(proteinG, carbsG, fatG) {
|
|
// Calculate macro distribution as percentages
|
|
const proteinCal = proteinG * 4;
|
|
const carbsCal = carbsG * 4;
|
|
const fatCal = fatG * 9;
|
|
const totalCal = proteinCal + carbsCal + fatCal;
|
|
|
|
if (totalCal === 0) {
|
|
return { protein: 0, carbs: 0, fat: 0 };
|
|
}
|
|
|
|
return {
|
|
protein: Math.round((proteinCal / totalCal) * 100),
|
|
carbs: Math.round((carbsCal / totalCal) * 100),
|
|
fat: Math.round((fatCal / totalCal) * 100)
|
|
};
|
|
}
|
|
|
|
function suggestFoodsForMacros(remainingProtein, remainingCarbs, remainingFat) {
|
|
// Suggest Filipino foods based on remaining macros
|
|
const suggestions = [];
|
|
|
|
// High protein needed
|
|
if (remainingProtein > 30) {
|
|
suggestions.push({
|
|
category: 'High Protein Ulam',
|
|
examples: ['Grilled Tilapia', 'Chicken Tinola', 'Grilled Chicken']
|
|
});
|
|
}
|
|
|
|
// High carbs needed
|
|
if (remainingCarbs > 40) {
|
|
suggestions.push({
|
|
category: 'Carbs',
|
|
examples: ['White Rice', 'Pandesal', 'Sweet Potato']
|
|
});
|
|
}
|
|
|
|
// High fat needed
|
|
if (remainingFat > 20) {
|
|
suggestions.push({
|
|
category: 'Healthy Fats',
|
|
examples: ['Sisig', 'Lechon Kawali', 'Bicol Express']
|
|
});
|
|
}
|
|
|
|
// Balanced meal needed
|
|
if (remainingProtein > 20 && remainingCarbs > 30) {
|
|
suggestions.push({
|
|
category: 'Balanced Meals',
|
|
examples: ['Tapsilog', 'Chicken Adobo with Rice', 'Sinigang']
|
|
});
|
|
}
|
|
|
|
return suggestions;
|
|
}
|
|
|
|
async function generateDailyMealPlan(userId, dateStr) {
|
|
try {
|
|
// Get user's calorie target
|
|
const user = await db.User.findByPk(userId);
|
|
const targetCalories = user ? user.target_daily_calories : 2000;
|
|
|
|
// Distribution: Breakfast 25%, Lunch 35%, Dinner 30%, Snack 10%
|
|
const targets = {
|
|
breakfast: Math.round(targetCalories * 0.25),
|
|
lunch: Math.round(targetCalories * 0.35),
|
|
dinner: Math.round(targetCalories * 0.30),
|
|
snack: Math.round(targetCalories * 0.10)
|
|
};
|
|
|
|
// Fetch all foods
|
|
const foods = await db.FoodItem.findAll();
|
|
|
|
// Categorize foods
|
|
const foodByCat = {
|
|
almusal: foods.filter(f => f.category === 'almusal'),
|
|
ulam: foods.filter(f => ['ulam', 'sabaw'].includes(f.category)),
|
|
kanin: foods.filter(f => f.category === 'kanin'),
|
|
gulay: foods.filter(f => f.category === 'gulay'),
|
|
meryenda: foods.filter(f => f.category === 'meryenda')
|
|
};
|
|
|
|
// Fallback if categories are empty (use all foods)
|
|
const allFoods = foods;
|
|
|
|
// Helper to pick random item
|
|
const pickRandom = (arr) => arr.length > 0 ? arr[Math.floor(Math.random() * arr.length)] : null;
|
|
|
|
// Helper to generate a meal
|
|
const generateMeal = async (mealType, targetCal) => {
|
|
let currentCal = 0;
|
|
const selectedFoods = [];
|
|
|
|
// Strategy per meal type
|
|
if (mealType === 'breakfast') {
|
|
// Try almusal first
|
|
const main = pickRandom(foodByCat.almusal) || pickRandom(allFoods);
|
|
if (main) {
|
|
selectedFoods.push({ food: main, qty: 1 });
|
|
currentCal += main.calories;
|
|
}
|
|
} else if (['lunch', 'dinner'].includes(mealType)) {
|
|
// Rice + Ulam + (maybe) Gulay
|
|
const rice = pickRandom(foodByCat.kanin);
|
|
const ulam = pickRandom(foodByCat.ulam) || pickRandom(allFoods);
|
|
const gulay = pickRandom(foodByCat.gulay);
|
|
|
|
if (rice) {
|
|
selectedFoods.push({ food: rice, qty: 1 });
|
|
currentCal += rice.calories;
|
|
}
|
|
if (ulam) {
|
|
selectedFoods.push({ food: ulam, qty: 1 });
|
|
currentCal += ulam.calories;
|
|
}
|
|
// Add vegetable if we have room
|
|
if (gulay && currentCal < targetCal) {
|
|
selectedFoods.push({ food: gulay, qty: 1 });
|
|
currentCal += gulay.calories;
|
|
}
|
|
} else if (mealType === 'snack') {
|
|
const snack = pickRandom(foodByCat.meryenda) || pickRandom(allFoods);
|
|
if (snack) {
|
|
selectedFoods.push({ food: snack, qty: 1 });
|
|
currentCal += snack.calories;
|
|
}
|
|
}
|
|
|
|
// Adjust quantities to closer match target (simple scaling)
|
|
if (currentCal > 0 && selectedFoods.length > 0) {
|
|
const ratio = targetCal / currentCal;
|
|
// Limit scaling to reasonable bounds (0.5x to 2.0x) to avoid weird portions
|
|
// But for simplicity, let's just create the plan as is, maybe adding another item if way under
|
|
|
|
// Create MealPlan
|
|
const mealPlan = await db.MealPlan.create({
|
|
UserId: userId,
|
|
date: dateStr,
|
|
meal_type: mealType,
|
|
is_completed: false
|
|
});
|
|
|
|
// Add foods
|
|
for (const item of selectedFoods) {
|
|
await db.PlannedFood.create({
|
|
MealPlanId: mealPlan.id,
|
|
FoodItemId: item.food.id,
|
|
quantity: item.qty
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Delete existing plans for this date? Or just append?
|
|
// Let's clear existing plans for this date to avoid duplicates if re-generating
|
|
await db.MealPlan.destroy({
|
|
where: {
|
|
UserId: userId,
|
|
date: dateStr
|
|
}
|
|
});
|
|
|
|
// Generate for each meal type
|
|
await generateMeal('breakfast', targets.breakfast);
|
|
await generateMeal('lunch', targets.lunch);
|
|
await generateMeal('dinner', targets.dinner);
|
|
await generateMeal('snack', targets.snack);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error generating meal plan:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
calculateBMR,
|
|
calculateTDEE,
|
|
calculateMacroTargets,
|
|
calculateDailyTotals,
|
|
calculateWaterTotal,
|
|
getWeightTrend,
|
|
getCalorieTrend,
|
|
updateDailySummary,
|
|
getMacroPercentages,
|
|
suggestFoodsForMacros,
|
|
generateDailyMealPlan
|
|
};
|