Files
calorie_tracker_2/utils/index.js
Jp 0fa0343798 feat(meal-planner): add auto-generate meal plan functionality
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.
2026-01-31 00:56:25 +08:00

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