initial commit
This commit is contained in:
213
utils/api_client.js
Normal file
213
utils/api_client.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const axios = require('axios');
|
||||
const db = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
class NutritionAPI {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = "https://api.api-ninjas.com/v1/nutrition";
|
||||
this.headers = { 'X-Api-Key': apiKey };
|
||||
this.cacheDurationDays = 30;
|
||||
}
|
||||
|
||||
async searchFood(query) {
|
||||
// Check cache first
|
||||
const cached = await this._getFromCache(query);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Make API request
|
||||
try {
|
||||
const response = await axios.get(this.baseUrl, {
|
||||
headers: this.headers,
|
||||
params: { query: query },
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.data;
|
||||
|
||||
// Cache the response
|
||||
await this._saveToCache(query, 'api_ninjas', data);
|
||||
|
||||
// Parse and return standardized format
|
||||
return this._parseApiResponse(data);
|
||||
} else {
|
||||
console.error(`API Error: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`API Request failed: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async _getFromCache(query) {
|
||||
const cacheEntry = await db.APICache.findOne({
|
||||
where: { query: query.toLowerCase() }
|
||||
});
|
||||
|
||||
if (cacheEntry) {
|
||||
// Check if cache is still valid (30 days)
|
||||
const age = (new Date() - new Date(cacheEntry.cached_at)) / (1000 * 60 * 60 * 24);
|
||||
if (age < this.cacheDurationDays) {
|
||||
const data = JSON.parse(cacheEntry.response_json);
|
||||
return this._parseApiResponse(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async _saveToCache(query, source, data) {
|
||||
try {
|
||||
let cacheEntry = await db.APICache.findOne({
|
||||
where: { query: query.toLowerCase() }
|
||||
});
|
||||
|
||||
if (cacheEntry) {
|
||||
// Update existing cache
|
||||
cacheEntry.response_json = JSON.stringify(data);
|
||||
cacheEntry.cached_at = new Date();
|
||||
await cacheEntry.save();
|
||||
} else {
|
||||
// Create new cache entry
|
||||
await db.APICache.create({
|
||||
query: query.toLowerCase(),
|
||||
api_source: source,
|
||||
response_json: JSON.stringify(data),
|
||||
cached_at: new Date()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_parseApiResponse(data) {
|
||||
return data.map(item => ({
|
||||
name: (item.name || '').replace(/\b\w/g, l => l.toUpperCase()), // Title case
|
||||
calories: item.calories || 0,
|
||||
protein_g: item.protein_g || 0,
|
||||
carbs_g: item.carbohydrates_total_g || 0,
|
||||
fat_g: item.fat_total_g || 0,
|
||||
fiber_g: item.fiber_g || 0,
|
||||
sugar_g: item.sugar_g || 0,
|
||||
sodium_mg: item.sodium_mg || 0,
|
||||
serving_size_g: item.serving_size_g || 100,
|
||||
source: 'api_ninjas'
|
||||
}));
|
||||
}
|
||||
|
||||
async saveFoodToDb(foodData) {
|
||||
try {
|
||||
// Check if food already exists
|
||||
const existing = await db.FoodItem.findOne({
|
||||
where: {
|
||||
name: foodData.name,
|
||||
source: foodData.source || 'api'
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new food item
|
||||
const food = await db.FoodItem.create({
|
||||
name: foodData.name,
|
||||
calories: foodData.calories,
|
||||
protein_g: foodData.protein_g || 0,
|
||||
carbs_g: foodData.carbs_g || 0,
|
||||
fat_g: foodData.fat_g || 0,
|
||||
fiber_g: foodData.fiber_g || 0,
|
||||
sugar_g: foodData.sugar_g || 0,
|
||||
sodium_mg: foodData.sodium_mg || 0,
|
||||
serving_size_g: foodData.serving_size_g || 100,
|
||||
serving_description: foodData.serving_description || '1 serving',
|
||||
source: foodData.source || 'api',
|
||||
api_data: JSON.stringify(foodData)
|
||||
});
|
||||
|
||||
return food;
|
||||
} catch (error) {
|
||||
console.error(`Error saving food to DB: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAllSources(query, apiClient) {
|
||||
const results = [];
|
||||
|
||||
// 1. Search Filipino foods first
|
||||
const filipinoFoods = await db.FoodItem.findAll({
|
||||
where: {
|
||||
is_filipino: true,
|
||||
[Op.or]: [
|
||||
{ name: { [Op.like]: `%${query}%` } },
|
||||
{ name_tagalog: { [Op.like]: `%${query}%` } }
|
||||
]
|
||||
},
|
||||
limit: 5
|
||||
});
|
||||
|
||||
for (const food of filipinoFoods) {
|
||||
results.push({
|
||||
id: food.id,
|
||||
name: food.name,
|
||||
name_tagalog: food.name_tagalog,
|
||||
calories: food.calories,
|
||||
protein_g: food.protein_g,
|
||||
carbs_g: food.carbs_g,
|
||||
fat_g: food.fat_g,
|
||||
serving_description: food.serving_description,
|
||||
source: 'filipino',
|
||||
category: food.category
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Search other local foods
|
||||
const otherFoods = await db.FoodItem.findAll({
|
||||
where: {
|
||||
is_filipino: false,
|
||||
name: { [Op.like]: `%${query}%` }
|
||||
},
|
||||
limit: 5
|
||||
});
|
||||
|
||||
for (const food of otherFoods) {
|
||||
results.push({
|
||||
id: food.id,
|
||||
name: food.name,
|
||||
calories: food.calories,
|
||||
protein_g: food.protein_g,
|
||||
carbs_g: food.carbs_g,
|
||||
fat_g: food.fat_g,
|
||||
serving_description: food.serving_description,
|
||||
source: food.source
|
||||
});
|
||||
}
|
||||
|
||||
// 3. If not enough results, search API
|
||||
if (results.length < 3 && apiClient && apiClient.apiKey) {
|
||||
const apiResults = await apiClient.searchFood(query);
|
||||
for (const foodData of apiResults.slice(0, 5)) {
|
||||
results.push({
|
||||
name: foodData.name,
|
||||
calories: foodData.calories,
|
||||
protein_g: foodData.protein_g,
|
||||
carbs_g: foodData.carbs_g,
|
||||
fat_g: foodData.fat_g,
|
||||
serving_size_g: foodData.serving_size_g,
|
||||
source: 'api_ninjas',
|
||||
api_data: foodData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { NutritionAPI, searchAllSources };
|
||||
340
utils/index.js
Normal file
340
utils/index.js
Normal file
@@ -0,0 +1,340 @@
|
||||
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: {
|
||||
user_id: 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: {
|
||||
user_id: 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: {
|
||||
user_id: 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: {
|
||||
user_id: 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: {
|
||||
user_id: userId,
|
||||
date: dateStr
|
||||
}
|
||||
});
|
||||
|
||||
if (!summary) {
|
||||
summary = await db.DailySummary.create({
|
||||
user_id: 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;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculateBMR,
|
||||
calculateTDEE,
|
||||
calculateMacroTargets,
|
||||
calculateDailyTotals,
|
||||
calculateWaterTotal,
|
||||
getWeightTrend,
|
||||
getCalorieTrend,
|
||||
updateDailySummary,
|
||||
getMacroPercentages,
|
||||
suggestFoodsForMacros
|
||||
};
|
||||
Reference in New Issue
Block a user