initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Healthcheck
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
|
CMD curl -f http://localhost:5000/ || exit 1
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
CMD ["npm", "start"]
|
||||||
BIN
data/calorie_tracker.db
Normal file
BIN
data/calorie_tracker.db
Normal file
Binary file not shown.
BIN
data/sessions.db
Normal file
BIN
data/sessions.db
Normal file
Binary file not shown.
191
models/index.js
Normal file
191
models/index.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
const Sequelize = require('sequelize');
|
||||||
|
const path = require('path');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
const dbPath = process.env.DATABASE_URL || 'sqlite://calorie_tracker.db';
|
||||||
|
const storagePath = dbPath.startsWith('sqlite://')
|
||||||
|
? dbPath.replace('sqlite://', '')
|
||||||
|
: path.join(__dirname, '../data/calorie_tracker.db');
|
||||||
|
|
||||||
|
// Ensure storage path is absolute or relative to cwd correctly
|
||||||
|
// For Docker, we use /app/data/calorie_tracker.db
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: storagePath.startsWith('/') ? storagePath : path.join(process.cwd(), 'data', 'calorie_tracker.db'),
|
||||||
|
logging: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = {};
|
||||||
|
|
||||||
|
// User Model
|
||||||
|
const User = sequelize.define('User', {
|
||||||
|
username: { type: Sequelize.STRING(80), unique: true, allowNull: false },
|
||||||
|
password: { type: Sequelize.STRING(200), allowNull: false },
|
||||||
|
name: Sequelize.STRING(100),
|
||||||
|
age: Sequelize.INTEGER,
|
||||||
|
gender: Sequelize.STRING(10),
|
||||||
|
height_cm: Sequelize.FLOAT,
|
||||||
|
weight_kg: Sequelize.FLOAT,
|
||||||
|
activity_level: { type: Sequelize.STRING(20), defaultValue: 'moderate' },
|
||||||
|
target_daily_calories: { type: Sequelize.INTEGER, defaultValue: 2000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method to verify password
|
||||||
|
User.prototype.validPassword = function(password) {
|
||||||
|
return bcrypt.compareSync(password, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to hash password
|
||||||
|
User.beforeCreate(async (user) => {
|
||||||
|
if (user.password) {
|
||||||
|
user.password = await bcrypt.hash(user.password, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// FoodItem Model
|
||||||
|
const FoodItem = sequelize.define('FoodItem', {
|
||||||
|
name: { type: Sequelize.STRING(200), allowNull: false },
|
||||||
|
name_tagalog: Sequelize.STRING(200),
|
||||||
|
category: Sequelize.STRING(50),
|
||||||
|
calories: { type: Sequelize.FLOAT, allowNull: false },
|
||||||
|
protein_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
carbs_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
fat_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
fiber_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
sugar_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
sodium_mg: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
serving_size_g: { type: Sequelize.FLOAT, defaultValue: 100 },
|
||||||
|
serving_description: Sequelize.STRING(100),
|
||||||
|
source: { type: Sequelize.STRING(20), defaultValue: 'manual' },
|
||||||
|
is_filipino: { type: Sequelize.BOOLEAN, defaultValue: false },
|
||||||
|
is_favorite: { type: Sequelize.BOOLEAN, defaultValue: false },
|
||||||
|
api_data: Sequelize.TEXT,
|
||||||
|
last_updated: { type: Sequelize.DATE, defaultValue: Sequelize.NOW }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Meal Model
|
||||||
|
const Meal = sequelize.define('Meal', {
|
||||||
|
date: { type: Sequelize.DATEONLY, allowNull: false, defaultValue: Sequelize.NOW },
|
||||||
|
meal_type: { type: Sequelize.STRING(20), allowNull: false },
|
||||||
|
time: Sequelize.TIME,
|
||||||
|
notes: Sequelize.TEXT
|
||||||
|
});
|
||||||
|
|
||||||
|
// MealFood Model
|
||||||
|
const MealFood = sequelize.define('MealFood', {
|
||||||
|
quantity: { type: Sequelize.FLOAT, allowNull: false, defaultValue: 1.0 },
|
||||||
|
quantity_grams: Sequelize.FLOAT,
|
||||||
|
calories_consumed: Sequelize.FLOAT,
|
||||||
|
protein_consumed: Sequelize.FLOAT,
|
||||||
|
carbs_consumed: Sequelize.FLOAT,
|
||||||
|
fat_consumed: Sequelize.FLOAT
|
||||||
|
});
|
||||||
|
|
||||||
|
// WaterLog Model
|
||||||
|
const WaterLog = sequelize.define('WaterLog', {
|
||||||
|
date: { type: Sequelize.DATEONLY, allowNull: false, defaultValue: Sequelize.NOW },
|
||||||
|
amount_ml: { type: Sequelize.INTEGER, allowNull: false },
|
||||||
|
time: { type: Sequelize.TIME, defaultValue: Sequelize.NOW }
|
||||||
|
});
|
||||||
|
|
||||||
|
// WeightLog Model
|
||||||
|
const WeightLog = sequelize.define('WeightLog', {
|
||||||
|
date: { type: Sequelize.DATEONLY, allowNull: false, defaultValue: Sequelize.NOW },
|
||||||
|
weight_kg: { type: Sequelize.FLOAT, allowNull: false },
|
||||||
|
body_fat_percentage: Sequelize.FLOAT,
|
||||||
|
notes: Sequelize.TEXT,
|
||||||
|
time: { type: Sequelize.TIME, defaultValue: Sequelize.NOW }
|
||||||
|
});
|
||||||
|
|
||||||
|
// MealPlan Model
|
||||||
|
const MealPlan = sequelize.define('MealPlan', {
|
||||||
|
date: { type: Sequelize.DATEONLY, allowNull: false },
|
||||||
|
meal_type: { type: Sequelize.STRING(20), allowNull: false },
|
||||||
|
is_completed: { type: Sequelize.BOOLEAN, defaultValue: false },
|
||||||
|
notes: Sequelize.TEXT
|
||||||
|
});
|
||||||
|
|
||||||
|
// PlannedFood Model
|
||||||
|
const PlannedFood = sequelize.define('PlannedFood', {
|
||||||
|
quantity: { type: Sequelize.FLOAT, allowNull: false, defaultValue: 1.0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// UserGoal Model
|
||||||
|
const UserGoal = sequelize.define('UserGoal', {
|
||||||
|
goal_type: { type: Sequelize.STRING(20), defaultValue: 'recomp' },
|
||||||
|
target_weight_kg: Sequelize.FLOAT,
|
||||||
|
weekly_goal_kg: { type: Sequelize.FLOAT, defaultValue: 0.5 },
|
||||||
|
target_protein_g: { type: Sequelize.INTEGER, defaultValue: 150 },
|
||||||
|
target_carbs_g: { type: Sequelize.INTEGER, defaultValue: 200 },
|
||||||
|
target_fat_g: { type: Sequelize.INTEGER, defaultValue: 60 },
|
||||||
|
target_water_ml: { type: Sequelize.INTEGER, defaultValue: 2000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DailySummary Model
|
||||||
|
const DailySummary = sequelize.define('DailySummary', {
|
||||||
|
date: { type: Sequelize.DATEONLY, allowNull: false },
|
||||||
|
total_calories: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
total_protein_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
total_carbs_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
total_fat_g: { type: Sequelize.FLOAT, defaultValue: 0 },
|
||||||
|
total_water_ml: { type: Sequelize.INTEGER, defaultValue: 0 },
|
||||||
|
calories_remaining: Sequelize.FLOAT,
|
||||||
|
weight_kg: Sequelize.FLOAT,
|
||||||
|
notes: Sequelize.TEXT
|
||||||
|
});
|
||||||
|
|
||||||
|
// APICache Model
|
||||||
|
const APICache = sequelize.define('APICache', {
|
||||||
|
query: { type: Sequelize.STRING(200), allowNull: false, unique: true },
|
||||||
|
api_source: Sequelize.STRING(50),
|
||||||
|
response_json: Sequelize.TEXT,
|
||||||
|
cached_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
User.hasMany(Meal, { onDelete: 'CASCADE' });
|
||||||
|
Meal.belongsTo(User);
|
||||||
|
|
||||||
|
User.hasMany(WeightLog, { onDelete: 'CASCADE' });
|
||||||
|
WeightLog.belongsTo(User);
|
||||||
|
|
||||||
|
User.hasMany(WaterLog, { onDelete: 'CASCADE' });
|
||||||
|
WaterLog.belongsTo(User);
|
||||||
|
|
||||||
|
User.hasMany(MealPlan, { onDelete: 'CASCADE' });
|
||||||
|
MealPlan.belongsTo(User);
|
||||||
|
|
||||||
|
User.hasOne(UserGoal, { onDelete: 'CASCADE' });
|
||||||
|
UserGoal.belongsTo(User);
|
||||||
|
|
||||||
|
Meal.hasMany(MealFood, { onDelete: 'CASCADE' });
|
||||||
|
MealFood.belongsTo(Meal);
|
||||||
|
|
||||||
|
FoodItem.hasMany(MealFood);
|
||||||
|
MealFood.belongsTo(FoodItem);
|
||||||
|
|
||||||
|
MealPlan.hasMany(PlannedFood, { onDelete: 'CASCADE' });
|
||||||
|
PlannedFood.belongsTo(MealPlan);
|
||||||
|
|
||||||
|
FoodItem.hasMany(PlannedFood);
|
||||||
|
PlannedFood.belongsTo(FoodItem);
|
||||||
|
|
||||||
|
User.hasMany(DailySummary, { onDelete: 'CASCADE' });
|
||||||
|
DailySummary.belongsTo(User);
|
||||||
|
|
||||||
|
db.sequelize = sequelize;
|
||||||
|
db.Sequelize = Sequelize;
|
||||||
|
db.User = User;
|
||||||
|
db.FoodItem = FoodItem;
|
||||||
|
db.Meal = Meal;
|
||||||
|
db.MealFood = MealFood;
|
||||||
|
db.WaterLog = WaterLog;
|
||||||
|
db.WeightLog = WeightLog;
|
||||||
|
db.MealPlan = MealPlan;
|
||||||
|
db.PlannedFood = PlannedFood;
|
||||||
|
db.UserGoal = UserGoal;
|
||||||
|
db.DailySummary = DailySummary;
|
||||||
|
db.APICache = APICache;
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
3232
package-lock.json
generated
Normal file
3232
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "calorie-tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Calorie Tracker - Filipino Food Edition",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"seed": "node scripts/seed.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"connect-sqlite3": "^0.9.13",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-ejs-layouts": "^2.5.1",
|
||||||
|
"express-flash": "^0.0.2",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"method-override": "^3.0.0",
|
||||||
|
"passport": "^0.6.0",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"sequelize": "^6.33.0",
|
||||||
|
"sqlite3": "^5.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
358
scripts/seed.js
Normal file
358
scripts/seed.js
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
const db = require('../models');
|
||||||
|
|
||||||
|
const filipinoFoods = [
|
||||||
|
// Rice (Kanin)
|
||||||
|
{
|
||||||
|
name: 'White Rice',
|
||||||
|
name_tagalog: 'Kanin',
|
||||||
|
category: 'kanin',
|
||||||
|
calories: 206,
|
||||||
|
protein_g: 4.3,
|
||||||
|
carbs_g: 45,
|
||||||
|
fat_g: 0.4,
|
||||||
|
serving_description: '1 cup cooked',
|
||||||
|
serving_size_g: 158
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fried Rice',
|
||||||
|
name_tagalog: 'Sinangag',
|
||||||
|
category: 'kanin',
|
||||||
|
calories: 280,
|
||||||
|
protein_g: 5,
|
||||||
|
carbs_g: 42,
|
||||||
|
fat_g: 10,
|
||||||
|
serving_description: '1 cup',
|
||||||
|
serving_size_g: 170
|
||||||
|
},
|
||||||
|
|
||||||
|
// Main Dishes (Ulam)
|
||||||
|
{
|
||||||
|
name: 'Chicken Adobo',
|
||||||
|
name_tagalog: 'Adobong Manok',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 350,
|
||||||
|
protein_g: 35,
|
||||||
|
carbs_g: 5,
|
||||||
|
fat_g: 20,
|
||||||
|
serving_description: '1 serving (2 pieces)',
|
||||||
|
serving_size_g: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pork Sinigang',
|
||||||
|
name_tagalog: 'Sinigang na Baboy',
|
||||||
|
category: 'sabaw',
|
||||||
|
calories: 280,
|
||||||
|
protein_g: 25,
|
||||||
|
carbs_g: 10,
|
||||||
|
fat_g: 15,
|
||||||
|
serving_description: '1 bowl',
|
||||||
|
serving_size_g: 350
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Chicken Tinola',
|
||||||
|
name_tagalog: 'Tinolang Manok',
|
||||||
|
category: 'sabaw',
|
||||||
|
calories: 200,
|
||||||
|
protein_g: 28,
|
||||||
|
carbs_g: 8,
|
||||||
|
fat_g: 6,
|
||||||
|
serving_description: '1 bowl',
|
||||||
|
serving_size_g: 350
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bicol Express',
|
||||||
|
name_tagalog: 'Bicol Express',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 400,
|
||||||
|
protein_g: 20,
|
||||||
|
carbs_g: 10,
|
||||||
|
fat_g: 32,
|
||||||
|
serving_description: '1 serving',
|
||||||
|
serving_size_g: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pork Sisig',
|
||||||
|
name_tagalog: 'Sisig',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 450,
|
||||||
|
protein_g: 25,
|
||||||
|
carbs_g: 8,
|
||||||
|
fat_g: 35,
|
||||||
|
serving_description: '1 serving',
|
||||||
|
serving_size_g: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Menudo',
|
||||||
|
name_tagalog: 'Menudo',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 320,
|
||||||
|
protein_g: 22,
|
||||||
|
carbs_g: 12,
|
||||||
|
fat_g: 20,
|
||||||
|
serving_description: '1 serving',
|
||||||
|
serving_size_g: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kare-Kare',
|
||||||
|
name_tagalog: 'Kare-Kare',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 380,
|
||||||
|
protein_g: 24,
|
||||||
|
carbs_g: 18,
|
||||||
|
fat_g: 25,
|
||||||
|
serving_description: '1 serving',
|
||||||
|
serving_size_g: 250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lechon Kawali',
|
||||||
|
name_tagalog: 'Lechon Kawali',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 500,
|
||||||
|
protein_g: 30,
|
||||||
|
carbs_g: 2,
|
||||||
|
fat_g: 42,
|
||||||
|
serving_description: '1 serving',
|
||||||
|
serving_size_g: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pork Nilaga',
|
||||||
|
name_tagalog: 'Nilagang Baboy',
|
||||||
|
category: 'sabaw',
|
||||||
|
calories: 280,
|
||||||
|
protein_g: 28,
|
||||||
|
carbs_g: 12,
|
||||||
|
fat_g: 14,
|
||||||
|
serving_description: '1 bowl',
|
||||||
|
serving_size_g: 350
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Beef Bulalo',
|
||||||
|
name_tagalog: 'Bulalo',
|
||||||
|
category: 'sabaw',
|
||||||
|
calories: 350,
|
||||||
|
protein_g: 32,
|
||||||
|
carbs_g: 8,
|
||||||
|
fat_g: 22,
|
||||||
|
serving_description: '1 bowl',
|
||||||
|
serving_size_g: 400
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vegetables (Gulay)
|
||||||
|
{
|
||||||
|
name: 'Pinakbet',
|
||||||
|
name_tagalog: 'Pinakbet',
|
||||||
|
category: 'gulay',
|
||||||
|
calories: 150,
|
||||||
|
protein_g: 5,
|
||||||
|
carbs_g: 20,
|
||||||
|
fat_g: 6,
|
||||||
|
serving_description: '1 cup',
|
||||||
|
serving_size_g: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Laing',
|
||||||
|
name_tagalog: 'Laing',
|
||||||
|
category: 'gulay',
|
||||||
|
calories: 180,
|
||||||
|
protein_g: 6,
|
||||||
|
carbs_g: 15,
|
||||||
|
fat_g: 12,
|
||||||
|
serving_description: '1 cup',
|
||||||
|
serving_size_g: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ginisang Monggo',
|
||||||
|
name_tagalog: 'Ginisang Monggo',
|
||||||
|
category: 'gulay',
|
||||||
|
calories: 200,
|
||||||
|
protein_g: 12,
|
||||||
|
carbs_g: 30,
|
||||||
|
fat_g: 4,
|
||||||
|
serving_description: '1 cup',
|
||||||
|
serving_size_g: 220
|
||||||
|
},
|
||||||
|
|
||||||
|
// Breakfast (Almusal)
|
||||||
|
{
|
||||||
|
name: 'Beef Tapa with Rice and Egg',
|
||||||
|
name_tagalog: 'Tapsilog',
|
||||||
|
category: 'almusal',
|
||||||
|
calories: 650,
|
||||||
|
protein_g: 45,
|
||||||
|
carbs_g: 60,
|
||||||
|
fat_g: 25,
|
||||||
|
serving_description: '1 plate',
|
||||||
|
serving_size_g: 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Longganisa with Rice and Egg',
|
||||||
|
name_tagalog: 'Longsilog',
|
||||||
|
category: 'almusal',
|
||||||
|
calories: 700,
|
||||||
|
protein_g: 38,
|
||||||
|
carbs_g: 65,
|
||||||
|
fat_g: 32,
|
||||||
|
serving_description: '1 plate',
|
||||||
|
serving_size_g: 420
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tocino with Rice and Egg',
|
||||||
|
name_tagalog: 'Tocilog',
|
||||||
|
category: 'almusal',
|
||||||
|
calories: 680,
|
||||||
|
protein_g: 42,
|
||||||
|
carbs_g: 62,
|
||||||
|
fat_g: 28,
|
||||||
|
serving_description: '1 plate',
|
||||||
|
serving_size_g: 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fried Egg',
|
||||||
|
name_tagalog: 'Pritong Itlog',
|
||||||
|
category: 'almusal',
|
||||||
|
calories: 90,
|
||||||
|
protein_g: 6,
|
||||||
|
carbs_g: 1,
|
||||||
|
fat_g: 7,
|
||||||
|
serving_description: '1 egg',
|
||||||
|
serving_size_g: 50
|
||||||
|
},
|
||||||
|
|
||||||
|
// Snacks (Meryenda)
|
||||||
|
{
|
||||||
|
name: 'Pandesal',
|
||||||
|
name_tagalog: 'Pandesal',
|
||||||
|
category: 'meryenda',
|
||||||
|
calories: 120,
|
||||||
|
protein_g: 3,
|
||||||
|
carbs_g: 22,
|
||||||
|
fat_g: 2,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Turon',
|
||||||
|
name_tagalog: 'Turon',
|
||||||
|
category: 'meryenda',
|
||||||
|
calories: 180,
|
||||||
|
protein_g: 2,
|
||||||
|
carbs_g: 35,
|
||||||
|
fat_g: 5,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bibingka',
|
||||||
|
name_tagalog: 'Bibingka',
|
||||||
|
category: 'meryenda',
|
||||||
|
calories: 220,
|
||||||
|
protein_g: 5,
|
||||||
|
carbs_g: 38,
|
||||||
|
fat_g: 6,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Puto',
|
||||||
|
name_tagalog: 'Puto',
|
||||||
|
category: 'meryenda',
|
||||||
|
calories: 90,
|
||||||
|
protein_g: 2,
|
||||||
|
carbs_g: 18,
|
||||||
|
fat_g: 1,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lumpia',
|
||||||
|
name_tagalog: 'Lumpia',
|
||||||
|
category: 'meryenda',
|
||||||
|
calories: 100,
|
||||||
|
protein_g: 4,
|
||||||
|
carbs_g: 10,
|
||||||
|
fat_g: 5,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Banana Cue',
|
||||||
|
name_tagalog: 'Banana Cue',
|
||||||
|
category: 'meryenda',
|
||||||
|
calories: 150,
|
||||||
|
protein_g: 1,
|
||||||
|
carbs_g: 32,
|
||||||
|
fat_g: 4,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 100
|
||||||
|
},
|
||||||
|
|
||||||
|
// Proteins
|
||||||
|
{
|
||||||
|
name: 'Grilled Tilapia',
|
||||||
|
name_tagalog: 'Inihaw na Tilapia',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 180,
|
||||||
|
protein_g: 32,
|
||||||
|
carbs_g: 0,
|
||||||
|
fat_g: 5,
|
||||||
|
serving_description: '1 whole fish',
|
||||||
|
serving_size_g: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Grilled Chicken',
|
||||||
|
name_tagalog: 'Inihaw na Manok',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 280,
|
||||||
|
protein_g: 40,
|
||||||
|
carbs_g: 0,
|
||||||
|
fat_g: 13,
|
||||||
|
serving_description: '1 breast',
|
||||||
|
serving_size_g: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fried Bangus',
|
||||||
|
name_tagalog: 'Pritong Bangus',
|
||||||
|
category: 'ulam',
|
||||||
|
calories: 220,
|
||||||
|
protein_g: 28,
|
||||||
|
carbs_g: 0,
|
||||||
|
fat_g: 12,
|
||||||
|
serving_description: '1 piece',
|
||||||
|
serving_size_g: 120
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
try {
|
||||||
|
await db.sequelize.sync();
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
|
||||||
|
for (const foodData of filipinoFoods) {
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await db.FoodItem.findOne({
|
||||||
|
where: {
|
||||||
|
name: foodData.name,
|
||||||
|
is_filipino: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.FoodItem.create({
|
||||||
|
...foodData,
|
||||||
|
source: 'filipino',
|
||||||
|
is_filipino: true
|
||||||
|
});
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully added ${addedCount} Filipino foods to the database!`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding Filipino foods:', error);
|
||||||
|
} finally {
|
||||||
|
await db.sequelize.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seed();
|
||||||
734
server.js
Normal file
734
server.js
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const session = require('express-session');
|
||||||
|
const passport = require('passport');
|
||||||
|
const LocalStrategy = require('passport-local').Strategy;
|
||||||
|
const expressLayouts = require('express-ejs-layouts');
|
||||||
|
const flash = require('express-flash');
|
||||||
|
const methodOverride = require('method-override');
|
||||||
|
const path = require('path');
|
||||||
|
const SQLiteStore = require('connect-sqlite3')(session);
|
||||||
|
const db = require('./models');
|
||||||
|
const utils = require('./utils');
|
||||||
|
const { NutritionAPI, searchAllSources } = require('./utils/api_client');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 5001;
|
||||||
|
|
||||||
|
// Initialize API Client
|
||||||
|
const apiClient = new NutritionAPI(process.env.API_NINJAS_KEY);
|
||||||
|
|
||||||
|
// Passport Config
|
||||||
|
passport.use(new LocalStrategy(
|
||||||
|
async (username, password, done) => {
|
||||||
|
try {
|
||||||
|
const user = await db.User.findOne({ where: { username } });
|
||||||
|
if (!user) {
|
||||||
|
return done(null, false, { message: 'Incorrect username.' });
|
||||||
|
}
|
||||||
|
if (!user.validPassword(password)) {
|
||||||
|
return done(null, false, { message: 'Incorrect password.' });
|
||||||
|
}
|
||||||
|
return done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
return done(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
passport.serializeUser((user, done) => {
|
||||||
|
done(null, user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.deserializeUser(async (id, done) => {
|
||||||
|
try {
|
||||||
|
const user = await db.User.findByPk(id);
|
||||||
|
done(null, user);
|
||||||
|
} catch (err) {
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
app.set('layout', 'layout');
|
||||||
|
app.use(expressLayouts);
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(methodOverride('_method'));
|
||||||
|
|
||||||
|
// Session
|
||||||
|
app.use(session({
|
||||||
|
store: new SQLiteStore({ db: 'sessions.db', dir: './data' }),
|
||||||
|
secret: process.env.SECRET_KEY || 'secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 } // 30 days
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
app.use(flash());
|
||||||
|
|
||||||
|
// Global Variables
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.locals.current_user = req.user;
|
||||||
|
res.locals.success_msg = req.flash('success_msg');
|
||||||
|
res.locals.error_msg = req.flash('error_msg');
|
||||||
|
res.locals.error = req.flash('error');
|
||||||
|
res.locals.path = req.path;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions for templates
|
||||||
|
app.locals.round = Math.round;
|
||||||
|
|
||||||
|
// Auth Middleware
|
||||||
|
function ensureAuthenticated(req, res, next) {
|
||||||
|
if (req.isAuthenticated()) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
req.flash('error_msg', 'Please log in to view that resource');
|
||||||
|
res.redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
if (req.isAuthenticated()) {
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
} else {
|
||||||
|
res.redirect('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
res.render('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/login', passport.authenticate('local', {
|
||||||
|
successRedirect: '/dashboard',
|
||||||
|
failureRedirect: '/login',
|
||||||
|
failureFlash: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/register', (req, res) => {
|
||||||
|
res.render('register');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/register', async (req, res) => {
|
||||||
|
const { username, password, name } = req.body;
|
||||||
|
try {
|
||||||
|
const existingUser = await db.User.findOne({ where: { username } });
|
||||||
|
if (existingUser) {
|
||||||
|
req.flash('error', 'Username already exists');
|
||||||
|
return res.redirect('/register');
|
||||||
|
}
|
||||||
|
await db.User.create({ username, password, name });
|
||||||
|
req.flash('success_msg', 'You are now registered and can log in');
|
||||||
|
res.redirect('/login');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error', 'Error registering user');
|
||||||
|
res.redirect('/register');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/logout', (req, res, next) => {
|
||||||
|
req.logout((err) => {
|
||||||
|
if (err) { return next(err); }
|
||||||
|
req.flash('success_msg', 'You are logged out');
|
||||||
|
res.redirect('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/dashboard', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get daily totals
|
||||||
|
const nutrition = await utils.calculateDailyTotals(req.user.id, dateStr);
|
||||||
|
const water = await utils.calculateWaterTotal(req.user.id, dateStr);
|
||||||
|
|
||||||
|
// Get user goals
|
||||||
|
let goals = await db.UserGoal.findOne({ where: { user_id: req.user.id } });
|
||||||
|
if (!goals) {
|
||||||
|
goals = await db.UserGoal.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
target_protein_g: 150,
|
||||||
|
target_carbs_g: 200,
|
||||||
|
target_fat_g: 60,
|
||||||
|
target_water_ml: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get weight info
|
||||||
|
const weightLogToday = await db.WeightLog.findOne({
|
||||||
|
where: { user_id: req.user.id, date: dateStr }
|
||||||
|
});
|
||||||
|
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const weightLogYesterday = await db.WeightLog.findOne({
|
||||||
|
where: { user_id: req.user.id, date: yesterdayStr }
|
||||||
|
});
|
||||||
|
|
||||||
|
let weightChange = null;
|
||||||
|
if (weightLogToday && weightLogYesterday) {
|
||||||
|
weightChange = weightLogToday.weight_kg - weightLogYesterday.weight_kg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate remaining
|
||||||
|
const remaining = {
|
||||||
|
calories: req.user.target_daily_calories - nutrition.calories,
|
||||||
|
protein: goals.target_protein_g - nutrition.protein,
|
||||||
|
carbs: goals.target_carbs_g - nutrition.carbs,
|
||||||
|
fat: goals.target_fat_g - nutrition.fat,
|
||||||
|
water: goals.target_water_ml - water.total_ml
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get macro percentages
|
||||||
|
const macroPercentages = utils.getMacroPercentages(
|
||||||
|
nutrition.protein,
|
||||||
|
nutrition.carbs,
|
||||||
|
nutrition.fat
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get trends
|
||||||
|
const weightTrend = await utils.getWeightTrend(req.user.id, 7);
|
||||||
|
const calorieTrend = await utils.getCalorieTrend(req.user.id, 7);
|
||||||
|
|
||||||
|
// Suggestions
|
||||||
|
const suggestions = utils.suggestFoodsForMacros(
|
||||||
|
remaining.protein,
|
||||||
|
remaining.carbs,
|
||||||
|
remaining.fat
|
||||||
|
);
|
||||||
|
|
||||||
|
res.render('dashboard', {
|
||||||
|
nutrition,
|
||||||
|
water,
|
||||||
|
goals,
|
||||||
|
remaining,
|
||||||
|
macro_percentages: macroPercentages,
|
||||||
|
weight_today: weightLogToday,
|
||||||
|
weight_change: weightChange,
|
||||||
|
weight_trend: weightTrend,
|
||||||
|
calorie_trend: calorieTrend,
|
||||||
|
suggestions,
|
||||||
|
today: dateStr
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading dashboard');
|
||||||
|
res.render('dashboard', {
|
||||||
|
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||||
|
water: { total_ml: 0 },
|
||||||
|
goals: { target_water_ml: 2000 },
|
||||||
|
remaining: { calories: 2000, protein: 150, carbs: 200, fat: 60, water: 2000 },
|
||||||
|
macro_percentages: { protein: 0, carbs: 0, fat: 0 },
|
||||||
|
weight_trend: [],
|
||||||
|
calorie_trend: [],
|
||||||
|
suggestions: [],
|
||||||
|
today: new Date().toISOString().split('T')[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/add-meal', ensureAuthenticated, (req, res) => {
|
||||||
|
res.render('add_meal', { today: new Date().toISOString().split('T')[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/add-meal', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { date, meal_type, time, 'food_id[]': foodIds, 'quantity[]': quantities } = req.body;
|
||||||
|
|
||||||
|
if (!foodIds) {
|
||||||
|
req.flash('error_msg', 'Please add at least one food item');
|
||||||
|
return res.redirect('/add-meal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize input (handle single item vs array)
|
||||||
|
if (!Array.isArray(foodIds)) {
|
||||||
|
foodIds = [foodIds];
|
||||||
|
quantities = [quantities];
|
||||||
|
}
|
||||||
|
|
||||||
|
const meal = await db.Meal.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
date: date || new Date(),
|
||||||
|
meal_type,
|
||||||
|
time: time || null
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < foodIds.length; i++) {
|
||||||
|
const foodId = foodIds[i];
|
||||||
|
const quantity = quantities[i];
|
||||||
|
|
||||||
|
if (foodId && quantity) {
|
||||||
|
const food = await db.FoodItem.findByPk(foodId);
|
||||||
|
if (food) {
|
||||||
|
await db.MealFood.create({
|
||||||
|
MealId: meal.id,
|
||||||
|
FoodItemId: foodId,
|
||||||
|
quantity: quantity,
|
||||||
|
calories_consumed: food.calories * quantity,
|
||||||
|
protein_consumed: food.protein_g * quantity,
|
||||||
|
carbs_consumed: food.carbs_g * quantity,
|
||||||
|
fat_consumed: food.fat_g * quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await utils.updateDailySummary(req.user.id, date);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Meal added successfully!');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error adding meal');
|
||||||
|
res.redirect('/add-meal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/search-food', ensureAuthenticated, async (req, res) => {
|
||||||
|
const query = req.query.q || '';
|
||||||
|
if (query.length < 2) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchAllSources(query, apiClient);
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/add-food', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = req.body;
|
||||||
|
const food = await apiClient.saveFoodToDb(data);
|
||||||
|
|
||||||
|
if (food) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
food_id: food.id,
|
||||||
|
name: food.name
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ success: false });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/add-water', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const amountMl = parseInt(req.body.amount_ml) || 250;
|
||||||
|
const date = req.body.date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
await db.WaterLog.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
date: date,
|
||||||
|
amount_ml: amountMl,
|
||||||
|
time: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.updateDailySummary(req.user.id, date);
|
||||||
|
|
||||||
|
req.flash('success_msg', `Added ${amountMl}ml of water!`);
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error adding water');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/add-weight', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const weightKg = parseFloat(req.body.weight_kg);
|
||||||
|
const date = req.body.date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
let weightLog = await db.WeightLog.findOne({
|
||||||
|
where: { user_id: req.user.id, date: date }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (weightLog) {
|
||||||
|
weightLog.weight_kg = weightKg;
|
||||||
|
weightLog.time = new Date();
|
||||||
|
await weightLog.save();
|
||||||
|
} else {
|
||||||
|
await db.WeightLog.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
date: date,
|
||||||
|
weight_kg: weightKg,
|
||||||
|
time: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await utils.updateDailySummary(req.user.id, date);
|
||||||
|
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error adding weight');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/goals', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userGoals = await db.UserGoal.findOne({ where: { user_id: req.user.id } });
|
||||||
|
|
||||||
|
let bmr = null;
|
||||||
|
let tdee = null;
|
||||||
|
|
||||||
|
if (req.user.weight_kg && req.user.height_cm && req.user.age) {
|
||||||
|
bmr = utils.calculateBMR(
|
||||||
|
req.user.weight_kg,
|
||||||
|
req.user.height_cm,
|
||||||
|
req.user.age,
|
||||||
|
req.user.gender || 'male'
|
||||||
|
);
|
||||||
|
tdee = utils.calculateTDEE(bmr, req.user.activity_level || 'moderate');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('goals', {
|
||||||
|
goals: userGoals,
|
||||||
|
bmr,
|
||||||
|
tdee
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading goals');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/goals', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Update user info
|
||||||
|
const user = await db.User.findByPk(req.user.id);
|
||||||
|
user.age = parseInt(req.body.age) || 25;
|
||||||
|
user.gender = req.body.gender || 'male';
|
||||||
|
user.height_cm = parseFloat(req.body.height_cm) || 170;
|
||||||
|
user.weight_kg = parseFloat(req.body.weight_kg) || 70;
|
||||||
|
user.activity_level = req.body.activity_level || 'moderate';
|
||||||
|
|
||||||
|
// Calculate targets
|
||||||
|
const bmr = utils.calculateBMR(user.weight_kg, user.height_cm, user.age, user.gender);
|
||||||
|
const tdee = utils.calculateTDEE(bmr, user.activity_level);
|
||||||
|
|
||||||
|
const goalType = req.body.goal_type || 'recomp';
|
||||||
|
let targetCalories;
|
||||||
|
|
||||||
|
if (goalType === 'weight_loss') {
|
||||||
|
targetCalories = tdee - 500;
|
||||||
|
} else if (goalType === 'muscle_gain') {
|
||||||
|
targetCalories = tdee + 300;
|
||||||
|
} else {
|
||||||
|
targetCalories = tdee;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.target_daily_calories = Math.round(targetCalories);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Update or create goals
|
||||||
|
let userGoals = await db.UserGoal.findOne({ where: { user_id: req.user.id } });
|
||||||
|
if (!userGoals) {
|
||||||
|
userGoals = await db.UserGoal.create({ user_id: req.user.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
userGoals.goal_type = goalType;
|
||||||
|
userGoals.target_weight_kg = parseFloat(req.body.target_weight_kg) || 70;
|
||||||
|
|
||||||
|
// Calculate macros
|
||||||
|
const macros = utils.calculateMacroTargets(user.weight_kg, goalType);
|
||||||
|
userGoals.target_protein_g = macros.protein_g;
|
||||||
|
userGoals.target_carbs_g = macros.carbs_g;
|
||||||
|
userGoals.target_fat_g = macros.fat_g;
|
||||||
|
userGoals.target_water_ml = parseInt(req.body.target_water_ml) || 2000;
|
||||||
|
|
||||||
|
await userGoals.save();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Goals updated successfully!');
|
||||||
|
res.redirect('/goals');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error updating goals');
|
||||||
|
res.redirect('/goals');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/foods', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = req.query.category || 'all';
|
||||||
|
const searchQuery = req.query.q || '';
|
||||||
|
|
||||||
|
const whereClause = {};
|
||||||
|
|
||||||
|
if (category !== 'all') {
|
||||||
|
whereClause.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
whereClause[db.Sequelize.Op.or] = [
|
||||||
|
{ name: { [db.Sequelize.Op.like]: `%${searchQuery}%` } },
|
||||||
|
{ name_tagalog: { [db.Sequelize.Op.like]: `%${searchQuery}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filipinoWhere = { ...whereClause, is_filipino: true };
|
||||||
|
const otherWhere = { ...whereClause, is_filipino: false };
|
||||||
|
|
||||||
|
const filipinoFoods = await db.FoodItem.findAll({
|
||||||
|
where: filipinoWhere,
|
||||||
|
order: [['name', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherFoods = await db.FoodItem.findAll({
|
||||||
|
where: otherWhere,
|
||||||
|
limit: 20,
|
||||||
|
order: [['name', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = ['all', 'kanin', 'ulam', 'sabaw', 'gulay', 'meryenda', 'almusal'];
|
||||||
|
|
||||||
|
res.render('foods', {
|
||||||
|
filipino_foods: filipinoFoods,
|
||||||
|
other_foods: otherFoods,
|
||||||
|
categories,
|
||||||
|
current_category: category,
|
||||||
|
search_query: searchQuery
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading foods');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/progress', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const days = parseInt(req.query.days) || 30;
|
||||||
|
|
||||||
|
const weightTrend = await utils.getWeightTrend(req.user.id, days);
|
||||||
|
const calorieTrend = await utils.getCalorieTrend(req.user.id, days);
|
||||||
|
|
||||||
|
let avgCalories = 0, avgProtein = 0, avgCarbs = 0, avgFat = 0;
|
||||||
|
|
||||||
|
if (calorieTrend.length > 0) {
|
||||||
|
avgCalories = calorieTrend.reduce((sum, d) => sum + d.calories, 0) / calorieTrend.length;
|
||||||
|
avgProtein = calorieTrend.reduce((sum, d) => sum + d.protein, 0) / calorieTrend.length;
|
||||||
|
avgCarbs = calorieTrend.reduce((sum, d) => sum + d.carbs, 0) / calorieTrend.length;
|
||||||
|
avgFat = calorieTrend.reduce((sum, d) => sum + d.fat, 0) / calorieTrend.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
let weightChange = null;
|
||||||
|
if (weightTrend.length >= 2) {
|
||||||
|
weightChange = weightTrend[weightTrend.length - 1].weight_kg - weightTrend[0].weight_kg;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('progress', {
|
||||||
|
weight_trend: weightTrend,
|
||||||
|
calorie_trend: calorieTrend,
|
||||||
|
avg_calories: Math.round(avgCalories),
|
||||||
|
avg_protein: Math.round(avgProtein),
|
||||||
|
avg_carbs: Math.round(avgCarbs),
|
||||||
|
avg_fat: Math.round(avgFat),
|
||||||
|
weight_change: weightChange,
|
||||||
|
days
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading progress');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/meal-planner', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
// Calculate Monday of current week
|
||||||
|
const day = today.getDay();
|
||||||
|
const diff = today.getDate() - day + (day === 0 ? -6 : 1); // adjust when day is sunday
|
||||||
|
const startDate = new Date(today);
|
||||||
|
startDate.setDate(diff);
|
||||||
|
|
||||||
|
const dates = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(startDate);
|
||||||
|
d.setDate(startDate.getDate() + i);
|
||||||
|
dates.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mealPlans = {};
|
||||||
|
|
||||||
|
for (const d of dates) {
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const plans = await db.MealPlan.findAll({
|
||||||
|
where: { user_id: req.user.id, date: dateStr },
|
||||||
|
include: [{
|
||||||
|
model: db.PlannedFood,
|
||||||
|
include: [db.FoodItem]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
mealPlans[dateStr] = plans.map(p => {
|
||||||
|
let calories = 0, protein = 0, carbs = 0, fat = 0;
|
||||||
|
const foods = [];
|
||||||
|
|
||||||
|
if (p.PlannedFoods) {
|
||||||
|
p.PlannedFoods.forEach(pf => {
|
||||||
|
if (pf.FoodItem) {
|
||||||
|
const q = pf.quantity || 1;
|
||||||
|
calories += pf.FoodItem.calories * q;
|
||||||
|
protein += pf.FoodItem.protein_g * q;
|
||||||
|
carbs += pf.FoodItem.carbs_g * q;
|
||||||
|
fat += pf.FoodItem.fat_g * q;
|
||||||
|
|
||||||
|
foods.push({
|
||||||
|
name: pf.FoodItem.name,
|
||||||
|
quantity: q
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
meal_type: p.meal_type,
|
||||||
|
is_completed: p.is_completed,
|
||||||
|
foods: foods,
|
||||||
|
totals: {
|
||||||
|
calories, protein, carbs, fat
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('meal_planner', {
|
||||||
|
dates: dates.map(d => d.toISOString().split('T')[0]),
|
||||||
|
meal_plans: mealPlans,
|
||||||
|
today: new Date().toISOString().split('T')[0]
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading meal planner');
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/meal-planner/add', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { date, meal_type, 'food_id[]': foodIds, 'quantity[]': quantities } = req.body;
|
||||||
|
|
||||||
|
if (!foodIds) {
|
||||||
|
req.flash('error_msg', 'Please add at least one food item');
|
||||||
|
return res.redirect('/meal-planner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize input
|
||||||
|
if (!Array.isArray(foodIds)) {
|
||||||
|
foodIds = [foodIds];
|
||||||
|
quantities = [quantities];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create meal plan
|
||||||
|
let mealPlan = await db.MealPlan.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: req.user.id,
|
||||||
|
date: date,
|
||||||
|
meal_type: meal_type
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mealPlan) {
|
||||||
|
mealPlan = await db.MealPlan.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
date: date,
|
||||||
|
meal_type: meal_type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add foods
|
||||||
|
for (let i = 0; i < foodIds.length; i++) {
|
||||||
|
const foodId = foodIds[i];
|
||||||
|
const quantity = quantities[i];
|
||||||
|
|
||||||
|
if (foodId && quantity) {
|
||||||
|
await db.PlannedFood.create({
|
||||||
|
MealPlanId: mealPlan.id,
|
||||||
|
FoodItemId: foodId,
|
||||||
|
quantity: quantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Meal plan updated!');
|
||||||
|
res.redirect('/meal-planner');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error adding meal plan');
|
||||||
|
res.redirect('/meal-planner');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/meal-planner/complete/:id', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const plan = await db.MealPlan.findOne({
|
||||||
|
where: { id: req.params.id, user_id: req.user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plan) {
|
||||||
|
plan.is_completed = !plan.is_completed;
|
||||||
|
await plan.save();
|
||||||
|
|
||||||
|
// If marked complete, optionally create actual meal log?
|
||||||
|
// For now just toggle status as per request simplicity.
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/meal-planner');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error updating plan status');
|
||||||
|
res.redirect('/meal-planner');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/meal-planner/delete/:id', ensureAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await db.MealPlan.destroy({
|
||||||
|
where: { id: req.params.id, user_id: req.user.id }
|
||||||
|
});
|
||||||
|
req.flash('success_msg', 'Meal plan deleted');
|
||||||
|
res.redirect('/meal-planner');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error deleting meal plan');
|
||||||
|
res.redirect('/meal-planner');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Database Sync and Server Start
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
await db.sequelize.sync();
|
||||||
|
app.listen(PORT, () => console.log(`Server started on port ${PORT}`));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Database connection error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startServer();
|
||||||
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
|
||||||
|
};
|
||||||
237
views/add_meal.ejs
Normal file
237
views/add_meal.ejs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-6">Add Meal</h1>
|
||||||
|
|
||||||
|
<form method="POST" id="mealForm" class="glass rounded-xl p-8 shadow-lg">
|
||||||
|
<!-- Meal Details -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||||
|
<input type="date" name="date" value="<%= today %>" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Meal Type</label>
|
||||||
|
<select name="meal_type" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
<option value="breakfast">🌅 Breakfast</option>
|
||||||
|
<option value="lunch">🌞 Lunch</option>
|
||||||
|
<option value="dinner">🌙 Dinner</option>
|
||||||
|
<option value="snack">🍪 Snack</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Time (optional)</label>
|
||||||
|
<input type="time" name="time" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Food Search -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Search Foods</label>
|
||||||
|
<input type="text" id="foodSearch" placeholder="Search Filipino or international foods..." class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary">
|
||||||
|
<div id="searchResults" class="mt-2 max-h-64 overflow-y-auto hidden bg-white border rounded-lg shadow-lg z-10 relative"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Foods -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-bold mb-3">Selected Foods</h3>
|
||||||
|
<div id="selectedFoods" class="space-y-3">
|
||||||
|
<p class="text-gray-500 text-center py-8" id="emptyMessage">No foods added yet. Search and add foods above.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded mb-6">
|
||||||
|
<h4 class="font-bold text-blue-900 mb-2">Meal Summary</h4>
|
||||||
|
<div class="grid grid-cols-4 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-blue-700">Calories</p>
|
||||||
|
<p class="text-xl font-bold text-blue-900" id="totalCalories">0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-blue-700">Protein</p>
|
||||||
|
<p class="text-xl font-bold text-blue-900" id="totalProtein">0g</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-blue-700">Carbs</p>
|
||||||
|
<p class="text-xl font-bold text-blue-900" id="totalCarbs">0g</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-blue-700">Fat</p>
|
||||||
|
<p class="text-xl font-bold text-blue-900" id="totalFat">0g</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<a href="/dashboard" class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-100 transition">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="bg-primary text-white px-8 py-3 rounded-lg hover:bg-red-700 transition">
|
||||||
|
Save Meal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedFoods = [];
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
// Food Search
|
||||||
|
document.getElementById('foodSearch').addEventListener('input', function(e) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const query = e.target.value;
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
document.getElementById('searchResults').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
fetch(`/api/search-food?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => displaySearchResults(data));
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
function displaySearchResults(results) {
|
||||||
|
const container = document.getElementById('searchResults');
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
container.innerHTML = '<p class="p-4 text-gray-500">No foods found</p>';
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = results.map(food => {
|
||||||
|
// Safe stringify for onclick attribute
|
||||||
|
const foodJson = JSON.stringify(food).replace(/"/g, '"');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="p-3 border-b hover:bg-gray-50 cursor-pointer transition-colors" onclick="addFood(${foodJson})">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">${food.name}</p>
|
||||||
|
${food.name_tagalog ? `<p class="text-sm text-gray-600">${food.name_tagalog}</p>` : ''}
|
||||||
|
<p class="text-xs text-gray-500">${food.serving_description || '1 serving'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-bold text-primary">${Math.round(food.calories)} cal</p>
|
||||||
|
<p class="text-xs text-gray-600">P:${Math.round(food.protein_g)}g C:${Math.round(food.carbs_g)}g F:${Math.round(food.fat_g)}g</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}).join('');
|
||||||
|
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFood(food) {
|
||||||
|
// Save to database if from API
|
||||||
|
if (food.source === 'api_ninjas' && !food.id) {
|
||||||
|
fetch('/api/add-food', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(food)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
food.id = data.food_id;
|
||||||
|
addFoodToList(food);
|
||||||
|
} else {
|
||||||
|
alert('Error adding food');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addFoodToList(food);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
document.getElementById('foodSearch').value = '';
|
||||||
|
document.getElementById('searchResults').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFoodToList(food) {
|
||||||
|
selectedFoods.push({...food, quantity: 1});
|
||||||
|
renderSelectedFoods();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFood(index) {
|
||||||
|
selectedFoods.splice(index, 1);
|
||||||
|
renderSelectedFoods();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuantity(index, quantity) {
|
||||||
|
selectedFoods[index].quantity = parseFloat(quantity);
|
||||||
|
updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedFoods() {
|
||||||
|
const container = document.getElementById('selectedFoods');
|
||||||
|
const emptyMessage = document.getElementById('emptyMessage');
|
||||||
|
|
||||||
|
if (selectedFoods.length === 0) {
|
||||||
|
emptyMessage.classList.remove('hidden');
|
||||||
|
container.innerHTML = ''; // Clear container but keep empty message logic
|
||||||
|
container.appendChild(emptyMessage);
|
||||||
|
updateSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyMessage.classList.add('hidden');
|
||||||
|
|
||||||
|
// Rebuild list
|
||||||
|
const listHtml = selectedFoods.map((food, index) => `
|
||||||
|
<div class="flex items-center justify-between p-4 border rounded-lg bg-white shadow-sm">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold">${food.name}</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
${Math.round(food.calories * food.quantity)} cal |
|
||||||
|
P:${Math.round(food.protein_g * food.quantity)}g
|
||||||
|
C:${Math.round(food.carbs_g * food.quantity)}g
|
||||||
|
F:${Math.round(food.fat_g * food.quantity)}g
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="food_id[]" value="${food.id}">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<label class="text-sm text-gray-600">Servings:</label>
|
||||||
|
<input type="number" step="0.1" min="0.1" name="quantity[]" value="${food.quantity}"
|
||||||
|
class="w-20 px-2 py-1 border rounded focus:ring-primary focus:border-primary"
|
||||||
|
onchange="updateQuantity(${index}, this.value)">
|
||||||
|
<button type="button" onclick="removeFood(${index})" class="text-red-600 hover:text-red-800 p-1 rounded hover:bg-red-50 transition">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = listHtml;
|
||||||
|
|
||||||
|
updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary() {
|
||||||
|
const totals = selectedFoods.reduce((acc, food) => ({
|
||||||
|
calories: acc.calories + (food.calories * food.quantity),
|
||||||
|
protein: acc.protein + (food.protein_g * food.quantity),
|
||||||
|
carbs: acc.carbs + (food.carbs_g * food.quantity),
|
||||||
|
fat: acc.fat + (food.fat_g * food.quantity)
|
||||||
|
}), {calories: 0, protein: 0, carbs: 0, fat: 0});
|
||||||
|
|
||||||
|
document.getElementById('totalCalories').textContent = Math.round(totals.calories);
|
||||||
|
document.getElementById('totalProtein').textContent = Math.round(totals.protein) + 'g';
|
||||||
|
document.getElementById('totalCarbs').textContent = Math.round(totals.carbs) + 'g';
|
||||||
|
document.getElementById('totalFat').textContent = Math.round(totals.fat) + 'g';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent form submission if no foods
|
||||||
|
document.getElementById('mealForm').addEventListener('submit', function(e) {
|
||||||
|
if (selectedFoods.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please add at least one food item');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
294
views/dashboard.ejs
Normal file
294
views/dashboard.ejs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Today's Summary</h1>
|
||||||
|
<p class="text-gray-600"><%= new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) %></p>
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<a href="/add-meal" class="bg-primary text-white px-6 py-3 rounded-lg hover:bg-red-700 transition inline-block">
|
||||||
|
➕ Add Meal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Cards Row -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
|
<!-- Calories Card -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 text-sm">Calories</p>
|
||||||
|
<h2 class="text-3xl font-bold text-primary"><%= Math.round(nutrition.calories) %></h2>
|
||||||
|
<p class="text-sm text-gray-500">/ <%= current_user.target_daily_calories %></p>
|
||||||
|
</div>
|
||||||
|
<span class="text-4xl">🔥</span>
|
||||||
|
</div>
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<% let cal_percent = current_user.target_daily_calories > 0 ? Math.round(nutrition.calories / current_user.target_daily_calories * 100) : 0; %>
|
||||||
|
<div class="bg-primary h-3 rounded-full transition-all" style="width: <%= Math.min(cal_percent, 100) %>%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-2 <%= remaining.calories >= 0 ? 'text-success' : 'text-red-600' %>">
|
||||||
|
<%= Math.round(remaining.calories) %> remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Protein Card -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 text-sm">Protein</p>
|
||||||
|
<h2 class="text-3xl font-bold text-secondary"><%= Math.round(nutrition.protein) %>g</h2>
|
||||||
|
<p class="text-sm text-gray-500">/ <%= goals.target_protein_g %>g</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-4xl">💪</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<% let prot_percent = goals.target_protein_g > 0 ? Math.round(nutrition.protein / goals.target_protein_g * 100) : 0; %>
|
||||||
|
<div class="bg-secondary h-3 rounded-full" style="width: <%= Math.min(prot_percent, 100) %>%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-2 <%= remaining.protein >= 0 ? 'text-success' : 'text-red-600' %>">
|
||||||
|
<%= Math.round(remaining.protein) %>g remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carbs & Fat Card -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 text-sm">Carbs</span>
|
||||||
|
<span class="font-bold text-warning"><%= Math.round(nutrition.carbs) %>g / <%= goals.target_carbs_g %>g</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||||
|
<% let carb_percent = goals.target_carbs_g > 0 ? Math.round(nutrition.carbs / goals.target_carbs_g * 100) : 0; %>
|
||||||
|
<div class="bg-warning h-2 rounded-full" style="width: <%= Math.min(carb_percent, 100) %>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 text-sm">Fat</span>
|
||||||
|
<span class="font-bold text-orange-600"><%= Math.round(nutrition.fat) %>g / <%= goals.target_fat_g %>g</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||||
|
<% let fat_percent = goals.target_fat_g > 0 ? Math.round(nutrition.fat / goals.target_fat_g * 100) : 0; %>
|
||||||
|
<div class="bg-orange-600 h-2 rounded-full" style="width: <%= Math.min(fat_percent, 100) %>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Water & Weight Card -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 text-sm">Water</span>
|
||||||
|
<span class="font-bold text-blue-600"><%= water.total_ml %>ml / <%= goals.target_water_ml %>ml</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-1 mt-2">
|
||||||
|
<% let glasses_filled = Math.floor(water.total_ml / 250); %>
|
||||||
|
<% for (let i = 0; i < 8; i++) { %>
|
||||||
|
<span class="<%= i < glasses_filled ? 'text-blue-500' : 'text-gray-300' %>">💧</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<!-- Quick add water buttons -->
|
||||||
|
<form method="POST" action="/add-water" class="flex space-x-1 mt-2">
|
||||||
|
<button type="submit" name="amount_ml" value="250" class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs hover:bg-blue-200">+250ml</button>
|
||||||
|
<button type="submit" name="amount_ml" value="500" class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs hover:bg-blue-200">+500ml</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="pt-3 border-t">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 text-sm">Weight</span>
|
||||||
|
<% if (typeof weight_today !== 'undefined' && weight_today) { %>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="font-bold"><%= weight_today.weight_kg %>kg</span>
|
||||||
|
<% if (typeof weight_change !== 'undefined' && weight_change !== null) { %>
|
||||||
|
<span class="text-xs <%= weight_change < 0 ? 'text-success' : 'text-red-600' %>">
|
||||||
|
<%= weight_change.toFixed(1) %>kg
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="POST" action="/add-weight" class="flex items-center space-x-2">
|
||||||
|
<input type="number" step="0.1" name="weight_kg" placeholder="kg" class="w-20 px-2 py-1 border rounded text-sm" required>
|
||||||
|
<button type="submit" class="bg-green-500 text-white px-2 py-1 rounded text-xs hover:bg-green-600">Log</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Macro Distribution Pie Chart -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 class="text-lg font-bold mb-4">Macro Distribution</h3>
|
||||||
|
<canvas id="macroChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suggestions -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg lg:col-span-2">
|
||||||
|
<h3 class="text-lg font-bold mb-4">💡 Smart Suggestions</h3>
|
||||||
|
<% if (suggestions && suggestions.length > 0) { %>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% suggestions.forEach(function(suggestion) { %>
|
||||||
|
<div class="bg-blue-50 border-l-4 border-blue-500 p-3 rounded">
|
||||||
|
<p class="font-semibold text-blue-900"><%= suggestion.category %></p>
|
||||||
|
<p class="text-sm text-blue-700">Try: <%= suggestion.examples.join(', ') %></p>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="text-gray-600">You're on track! Keep up the good work! 🎉</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today's Meals -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg mb-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-bold">Today's Meals</h3>
|
||||||
|
<a href="/add-meal" class="text-primary hover:underline">+ Add Meal</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (nutrition.meals && nutrition.meals.length > 0) { %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% nutrition.meals.forEach(function(meal) { %>
|
||||||
|
<div class="border-l-4 <%= meal.type == 'breakfast' ? 'border-yellow-500' : (meal.type == 'lunch' ? 'border-green-500' : (meal.type == 'dinner' ? 'border-blue-500' : 'border-purple-500')) %> pl-4 py-2">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-2xl">
|
||||||
|
<% if (meal.type == 'breakfast') { %>🌅<% } else if (meal.type == 'lunch') { %>🌞<% } else if (meal.type == 'dinner') { %>🌙<% } else { %>🍪<% } %>
|
||||||
|
</span>
|
||||||
|
<h4 class="font-bold text-lg capitalize"><%= meal.type %></h4>
|
||||||
|
<% if (meal.time) { %>
|
||||||
|
<span class="text-sm text-gray-500"><%= meal.time %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<% meal.foods.forEach(function(food) { %>
|
||||||
|
<p class="text-sm text-gray-700">• <%= food.name %> (<%= food.quantity %>x) - <%= Math.round(food.calories) %> cal</p>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-2xl font-bold text-primary"><%= Math.round(meal.totals.calories) %></p>
|
||||||
|
<p class="text-xs text-gray-500">calories</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">
|
||||||
|
P: <%= Math.round(meal.totals.protein) %>g |
|
||||||
|
C: <%= Math.round(meal.totals.carbs) %>g |
|
||||||
|
F: <%= Math.round(meal.totals.fat) %>g
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center py-12 text-gray-500">
|
||||||
|
<p class="text-4xl mb-4">🍽️</p>
|
||||||
|
<p>No meals logged yet today.</p>
|
||||||
|
<a href="/add-meal" class="text-primary hover:underline mt-2 inline-block">Add your first meal</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly Trends -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Calorie Trend -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 class="text-lg font-bold mb-4">📈 Calorie Trend (7 Days)</h3>
|
||||||
|
<canvas id="calorieChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weight Trend -->
|
||||||
|
<div class="glass rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 class="text-lg font-bold mb-4">⚖️ Weight Trend (7 Days)</h3>
|
||||||
|
<canvas id="weightChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Macro Distribution Chart
|
||||||
|
const macroCtx = document.getElementById('macroChart').getContext('2d');
|
||||||
|
new Chart(macroCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Protein', 'Carbs', 'Fat'],
|
||||||
|
datasets: [{
|
||||||
|
data: [<%= macro_percentages.protein %>, <%= macro_percentages.carbs %>, <%= macro_percentages.fat %>],
|
||||||
|
backgroundColor: ['#003F87', '#FFB703', '#FF6B35']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calorie Trend Chart
|
||||||
|
const calorieCtx = document.getElementById('calorieChart').getContext('2d');
|
||||||
|
new Chart(calorieCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: <%- JSON.stringify(calorie_trend.map(d => d.date)) %>,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Calories',
|
||||||
|
data: <%- JSON.stringify(calorie_trend.map(d => d.calories)) %>,
|
||||||
|
borderColor: '#D62828',
|
||||||
|
backgroundColor: 'rgba(214, 40, 40, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}, {
|
||||||
|
label: 'Target',
|
||||||
|
data: Array(7).fill(<%= current_user.target_daily_calories %>),
|
||||||
|
borderColor: '#06D6A0',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weight Trend Chart
|
||||||
|
<% if (weight_trend && weight_trend.length > 0) { %>
|
||||||
|
const weightCtx = document.getElementById('weightChart').getContext('2d');
|
||||||
|
new Chart(weightCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: <%- JSON.stringify(weight_trend.map(d => d.date)) %>,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Weight (kg)',
|
||||||
|
data: <%- JSON.stringify(weight_trend.map(d => d.weight_kg)) %>,
|
||||||
|
borderColor: '#003F87',
|
||||||
|
backgroundColor: 'rgba(0, 63, 135, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
<% } %>
|
||||||
|
</script>
|
||||||
104
views/foods.ejs
Normal file
104
views/foods.ejs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<h1 class="text-3xl font-bold mb-6">Food Database</h1>
|
||||||
|
|
||||||
|
<!-- Search and Filter -->
|
||||||
|
<div class="glass rounded-xl p-6 mb-8">
|
||||||
|
<form method="GET" action="/foods" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<input type="text" name="q" value="<%= search_query %>" placeholder="Search for foods (English or Tagalog)..." class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select name="category" onchange="this.form.submit()" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<% categories.forEach(cat => { %>
|
||||||
|
<option value="<%= cat %>" <%= current_category === cat ? 'selected' : '' %>><%= cat.charAt(0).toUpperCase() + cat.slice(1) %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center">
|
||||||
|
<span class="mr-2">🇵🇭</span> Filipino Foods
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<% if (filipino_foods.length === 0) { %>
|
||||||
|
<p class="text-gray-500 italic">No Filipino foods found matching your criteria.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<% filipino_foods.forEach(food => { %>
|
||||||
|
<div class="glass rounded-xl p-4 hover:shadow-lg transition">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-lg"><%= food.name %></h3>
|
||||||
|
<% if (food.name_tagalog) { %>
|
||||||
|
<p class="text-sm text-gray-600 italic"><%= food.name_tagalog %></p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<span class="bg-primary text-white text-xs px-2 py-1 rounded-full"><%= food.category %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-2 text-center text-sm mt-3">
|
||||||
|
<div class="bg-red-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-primary"><%= Math.round(food.calories) %></span>
|
||||||
|
<span class="text-xs text-gray-500">cal</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-blue-600"><%= Math.round(food.protein_g) %>g</span>
|
||||||
|
<span class="text-xs text-gray-500">prot</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-yellow-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-yellow-600"><%= Math.round(food.carbs_g) %>g</span>
|
||||||
|
<span class="text-xs text-gray-500">carb</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-orange-600"><%= Math.round(food.fat_g) %>g</span>
|
||||||
|
<span class="text-xs text-gray-500">fat</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 text-center">
|
||||||
|
Per <%= food.serving_description || 'serving' %> (<%= food.serving_size_g %>g)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Other Foods</h2>
|
||||||
|
|
||||||
|
<% if (other_foods.length === 0) { %>
|
||||||
|
<p class="text-gray-500 italic">No other foods found.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<% other_foods.forEach(food => { %>
|
||||||
|
<div class="glass rounded-xl p-4 hover:shadow-lg transition opacity-90">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h3 class="font-bold text-lg"><%= food.name %></h3>
|
||||||
|
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Global</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-2 text-center text-sm mt-3">
|
||||||
|
<div class="bg-red-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-primary"><%= Math.round(food.calories) %></span>
|
||||||
|
<span class="text-xs text-gray-500">cal</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-blue-600"><%= Math.round(food.protein_g) %>g</span>
|
||||||
|
<span class="text-xs text-gray-500">prot</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-yellow-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-yellow-600"><%= Math.round(food.carbs_g) %>g</span>
|
||||||
|
<span class="text-xs text-gray-500">carb</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-50 rounded p-1">
|
||||||
|
<span class="block font-bold text-orange-600"><%= Math.round(food.fat_g) %>g</span>
|
||||||
|
<span class="text-xs text-gray-500">fat</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
117
views/goals.ejs
Normal file
117
views/goals.ejs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<h1 class="text-3xl font-bold mb-6">Goals & Settings</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Personal Info Form -->
|
||||||
|
<div class="glass rounded-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-primary">Personal Information</h2>
|
||||||
|
<form method="POST" action="/goals">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Age</label>
|
||||||
|
<input type="number" name="age" value="<%= current_user.age || 25 %>" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Gender</label>
|
||||||
|
<select name="gender" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
<option value="male" <%= current_user.gender === 'male' ? 'selected' : '' %>>Male</option>
|
||||||
|
<option value="female" <%= current_user.gender === 'female' ? 'selected' : '' %>>Female</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Height (cm)</label>
|
||||||
|
<input type="number" step="0.1" name="height_cm" value="<%= current_user.height_cm || 170 %>" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Weight (kg)</label>
|
||||||
|
<input type="number" step="0.1" name="weight_kg" value="<%= current_user.weight_kg || 70 %>" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2">Activity Level</label>
|
||||||
|
<select name="activity_level" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
<option value="sedentary" <%= current_user.activity_level === 'sedentary' ? 'selected' : '' %>>Sedentary (Office job)</option>
|
||||||
|
<option value="light" <%= current_user.activity_level === 'light' ? 'selected' : '' %>>Light (Exercise 1-3 days/week)</option>
|
||||||
|
<option value="moderate" <%= current_user.activity_level === 'moderate' ? 'selected' : '' %>>Moderate (Exercise 3-5 days/week)</option>
|
||||||
|
<option value="active" <%= current_user.activity_level === 'active' ? 'selected' : '' %>>Active (Exercise 6-7 days/week)</option>
|
||||||
|
<option value="very_active" <%= current_user.activity_level === 'very_active' ? 'selected' : '' %>>Very Active (Physical job/training)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-6 border-gray-200">
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-primary">Goals</h2>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2">Goal Type</label>
|
||||||
|
<select name="goal_type" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
<option value="weight_loss" <%= goals && goals.goal_type === 'weight_loss' ? 'selected' : '' %>>Weight Loss</option>
|
||||||
|
<option value="recomp" <%= (!goals || goals.goal_type === 'recomp') ? 'selected' : '' %>>Maintain / Recomposition</option>
|
||||||
|
<option value="muscle_gain" <%= goals && goals.goal_type === 'muscle_gain' ? 'selected' : '' %>>Muscle Gain</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Weight (kg)</label>
|
||||||
|
<input type="number" step="0.1" name="target_weight_kg" value="<%= goals ? goals.target_weight_kg : 70 %>" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Target Water (ml)</label>
|
||||||
|
<input type="number" name="target_water_ml" value="<%= goals ? goals.target_water_ml : 2000 %>" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full bg-primary text-white font-bold py-3 px-4 rounded-lg hover:bg-red-700 transition duration-300">
|
||||||
|
Update Goals & Recalculate Macros
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calculated Stats -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="glass rounded-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-primary">Your Stats</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="p-4 bg-red-50 rounded-lg">
|
||||||
|
<p class="text-sm text-gray-500 mb-1">BMR</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800"><%= bmr || '-' %></p>
|
||||||
|
<p class="text-xs text-gray-400">Calories burned at rest</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-green-50 rounded-lg">
|
||||||
|
<p class="text-sm text-gray-500 mb-1">TDEE</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-800"><%= tdee || '-' %></p>
|
||||||
|
<p class="text-xs text-gray-400">Total daily energy expenditure</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (goals) { %>
|
||||||
|
<div class="glass rounded-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-primary">Daily Targets</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center p-3 border-b">
|
||||||
|
<span class="text-gray-600">Calories</span>
|
||||||
|
<span class="font-bold text-lg"><%= current_user.target_daily_calories %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-3 border-b">
|
||||||
|
<span class="text-gray-600">Protein</span>
|
||||||
|
<span class="font-bold"><%= goals.target_protein_g %>g</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-3 border-b">
|
||||||
|
<span class="text-gray-600">Carbs</span>
|
||||||
|
<span class="font-bold"><%= goals.target_carbs_g %>g</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-3 border-b">
|
||||||
|
<span class="text-gray-600">Fat</span>
|
||||||
|
<span class="font-bold"><%= goals.target_fat_g %>g</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-3">
|
||||||
|
<span class="text-gray-600">Water</span>
|
||||||
|
<span class="font-bold"><%= goals.target_water_ml %>ml</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
104
views/layout.ejs
Normal file
104
views/layout.ejs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Calorie Tracker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#D62828',
|
||||||
|
secondary: '#003F87',
|
||||||
|
success: '#06D6A0',
|
||||||
|
warning: '#FFB703',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<% if (current_user) { %>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="bg-primary text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex justify-between items-center py-4">
|
||||||
|
<div class="flex items-center space-x-8">
|
||||||
|
<a href="/" class="text-2xl font-bold">🍽️ Calorie Tracker</a>
|
||||||
|
<div class="hidden md:flex space-x-6">
|
||||||
|
<a href="/dashboard" class="hover:text-gray-200 transition <%= path === '/dashboard' ? 'font-bold' : '' %>">
|
||||||
|
🏠 Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/meal-planner" class="hover:text-gray-200 transition <%= path === '/meal-planner' ? 'font-bold' : '' %>">
|
||||||
|
📅 Meal Planner
|
||||||
|
</a>
|
||||||
|
<a href="/foods" class="hover:text-gray-200 transition <%= path === '/foods' ? 'font-bold' : '' %>">
|
||||||
|
🍛 Foods
|
||||||
|
</a>
|
||||||
|
<a href="/progress" class="hover:text-gray-200 transition <%= path === '/progress' ? 'font-bold' : '' %>">
|
||||||
|
📊 Progress
|
||||||
|
</a>
|
||||||
|
<a href="/goals" class="hover:text-gray-200 transition <%= path === '/goals' ? 'font-bold' : '' %>">
|
||||||
|
🎯 Goals
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="hidden md:inline">👤 <%= current_user.name || current_user.username %></span>
|
||||||
|
<a href="/logout" class="bg-white text-primary px-4 py-2 rounded hover:bg-gray-100 transition">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
<% if (success_msg.length > 0) { %>
|
||||||
|
<div class="container mx-auto px-4 mt-4">
|
||||||
|
<div class="bg-green-100 border-green-400 text-green-700 border px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span class="block sm:inline"><%= success_msg %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (error_msg.length > 0) { %>
|
||||||
|
<div class="container mx-auto px-4 mt-4">
|
||||||
|
<div class="bg-red-100 border-red-400 text-red-700 border px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span class="block sm:inline"><%= error_msg %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (error.length > 0) { %>
|
||||||
|
<div class="container mx-auto px-4 mt-4">
|
||||||
|
<div class="bg-red-100 border-red-400 text-red-700 border px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span class="block sm:inline"><%= error %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<%- body %>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-800 text-white text-center py-4 mt-12">
|
||||||
|
<p>© 2026 Calorie Tracker - Filipino Food Edition</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<%- defineContent('scripts') %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
views/login.ejs
Normal file
26
views/login.ejs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="glass rounded-xl p-8 shadow-lg">
|
||||||
|
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">🍽️ Calorie Tracker</h1>
|
||||||
|
<p class="text-center text-gray-600 mb-8">Filipino Food Edition</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||||
|
<input type="text" name="username" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||||
|
<input type="password" name="password" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full bg-primary text-white py-3 rounded-lg hover:bg-red-700 transition font-semibold">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center mt-6 text-gray-600">
|
||||||
|
Don't have an account? <a href="/register" class="text-primary hover:underline font-semibold">Register</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
253
views/meal_planner.ejs
Normal file
253
views/meal_planner.ejs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<h1 class="text-3xl font-bold mb-6">Weekly Meal Planner</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-7 gap-4 overflow-x-auto pb-4">
|
||||||
|
<% dates.forEach(dateStr => { %>
|
||||||
|
<% const dateObj = new Date(dateStr); %>
|
||||||
|
<% const isToday = dateStr === today; %>
|
||||||
|
|
||||||
|
<div class="min-w-[250px] md:min-w-0 flex flex-col h-full">
|
||||||
|
<!-- Date Header -->
|
||||||
|
<div class="p-3 rounded-t-xl text-center <%= isToday ? 'bg-primary text-white' : 'bg-white border-t border-x' %>">
|
||||||
|
<p class="text-xs font-bold uppercase"><%= dateObj.toLocaleDateString('en-US', { weekday: 'short' }) %></p>
|
||||||
|
<p class="text-lg font-bold"><%= dateObj.getDate() %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meal Column -->
|
||||||
|
<div class="bg-gray-50 border-x border-b rounded-b-xl flex-1 p-2 space-y-3 min-h-[500px]">
|
||||||
|
<% if (meal_plans[dateStr] && meal_plans[dateStr].length > 0) { %>
|
||||||
|
<% meal_plans[dateStr].forEach(plan => { %>
|
||||||
|
<div class="bg-white p-3 rounded-lg shadow-sm border border-gray-100 hover:shadow-md transition relative group <%= plan.is_completed ? 'opacity-60' : '' %>">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="text-xs font-bold uppercase text-gray-500"><%= plan.meal_type %></span>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<!-- Complete Toggle -->
|
||||||
|
<form action="/meal-planner/complete/<%= plan.id %>" method="POST" class="inline">
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-green-500 transition" title="<%= plan.is_completed ? 'Mark Incomplete' : 'Mark Complete' %>">
|
||||||
|
<% if (plan.is_completed) { %>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<% } else { %>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<% } %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<form action="/meal-planner/delete/<%= plan.id %>" method="POST" class="inline" onsubmit="return confirm('Delete this meal plan?');">
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 transition opacity-0 group-hover:opacity-100">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="text-sm space-y-1 mb-2">
|
||||||
|
<% plan.foods.forEach(food => { %>
|
||||||
|
<li class="truncate <%= plan.is_completed ? 'line-through text-gray-400' : '' %>">• <%= food.name %></li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-400 flex justify-between pt-2 border-t">
|
||||||
|
<span><%= Math.round(plan.totals.calories) %> cal</span>
|
||||||
|
<span>P:<%= Math.round(plan.totals.protein) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center py-8 text-gray-400 text-sm">
|
||||||
|
No meals planned
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Add Button -->
|
||||||
|
<button onclick="openPlanModal('<%= dateStr %>')" class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-400 hover:border-primary hover:text-primary transition text-sm mt-auto">
|
||||||
|
+ Plan Meal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan Meal Modal -->
|
||||||
|
<div id="planModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50 p-4">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6 border-b flex justify-between items-center">
|
||||||
|
<h3 class="text-xl font-bold">Plan a Meal</h3>
|
||||||
|
<button onclick="closePlanModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/meal-planner/add" method="POST" id="planForm" class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||||
|
<input type="date" name="date" id="planDate" class="w-full px-4 py-2 border rounded-lg bg-gray-50" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Meal Type</label>
|
||||||
|
<select name="meal_type" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
<option value="breakfast">🌅 Breakfast</option>
|
||||||
|
<option value="lunch">🌞 Lunch</option>
|
||||||
|
<option value="dinner">🌙 Dinner</option>
|
||||||
|
<option value="snack">🍪 Snack</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Food Search (Simplified from add_meal) -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Add Foods</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="foodSearch" placeholder="Search foods..." class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary">
|
||||||
|
<div id="searchResults" class="absolute left-0 right-0 mt-1 max-h-64 overflow-y-auto hidden bg-white border rounded-lg shadow-lg z-10"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Foods List -->
|
||||||
|
<div class="mb-6 bg-gray-50 rounded-lg p-4">
|
||||||
|
<h4 class="font-bold text-sm mb-2 text-gray-700">Selected Foods:</h4>
|
||||||
|
<div id="selectedFoods" class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-400 text-center py-2" id="emptyMessage">No foods added</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button type="button" onclick="closePlanModal()" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-red-700">Save Plan</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedFoods = [];
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
function openPlanModal(date) {
|
||||||
|
document.getElementById('planDate').value = date;
|
||||||
|
document.getElementById('planModal').classList.remove('hidden');
|
||||||
|
document.getElementById('planModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlanModal() {
|
||||||
|
document.getElementById('planModal').classList.add('hidden');
|
||||||
|
document.getElementById('planModal').classList.remove('flex');
|
||||||
|
selectedFoods = [];
|
||||||
|
renderSelectedFoods();
|
||||||
|
document.getElementById('foodSearch').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Food Search Logic (Copied & Simplified)
|
||||||
|
document.getElementById('foodSearch').addEventListener('input', function(e) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const query = e.target.value;
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
document.getElementById('searchResults').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
fetch(`/api/search-food?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => displaySearchResults(data));
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
function displaySearchResults(results) {
|
||||||
|
const container = document.getElementById('searchResults');
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
container.innerHTML = '<p class="p-4 text-gray-500">No foods found</p>';
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = results.map(food => {
|
||||||
|
const foodJson = JSON.stringify(food).replace(/"/g, '"');
|
||||||
|
return `
|
||||||
|
<div class="p-3 border-b hover:bg-gray-50 cursor-pointer transition-colors" onclick="addFood(${foodJson})">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="font-medium">${food.name}</span>
|
||||||
|
<span class="text-primary text-sm">${Math.round(food.calories)} cal</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">${food.serving_description || '1 serving'}</p>
|
||||||
|
</div>
|
||||||
|
`}).join('');
|
||||||
|
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFood(food) {
|
||||||
|
// If from API and new, save it first (simplified reuse of logic)
|
||||||
|
if (food.source === 'api_ninjas' && !food.id) {
|
||||||
|
fetch('/api/add-food', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(food)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
food.id = data.food_id;
|
||||||
|
addFoodToList(food);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addFoodToList(food);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('foodSearch').value = '';
|
||||||
|
document.getElementById('searchResults').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFoodToList(food) {
|
||||||
|
selectedFoods.push({...food, quantity: 1});
|
||||||
|
renderSelectedFoods();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFood(index) {
|
||||||
|
selectedFoods.splice(index, 1);
|
||||||
|
renderSelectedFoods();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedFoods() {
|
||||||
|
const container = document.getElementById('selectedFoods');
|
||||||
|
const emptyMessage = document.getElementById('emptyMessage');
|
||||||
|
|
||||||
|
if (selectedFoods.length === 0) {
|
||||||
|
emptyMessage.classList.remove('hidden');
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(emptyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyMessage.classList.add('hidden');
|
||||||
|
|
||||||
|
container.innerHTML = selectedFoods.map((food, index) => `
|
||||||
|
<div class="flex items-center justify-between p-2 bg-white rounded border text-sm">
|
||||||
|
<span class="font-medium">${food.name}</span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="number" step="0.1" name="quantity[]" value="${food.quantity}" class="w-16 px-1 border rounded" onchange="selectedFoods[${index}].quantity=this.value">
|
||||||
|
<input type="hidden" name="food_id[]" value="${food.id}">
|
||||||
|
<button type="button" onclick="removeFood(${index})" class="text-red-500 hover:text-red-700">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('planForm').addEventListener('submit', function(e) {
|
||||||
|
if (selectedFoods.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please add at least one food item');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
117
views/progress.ejs
Normal file
117
views/progress.ejs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<h1 class="text-3xl font-bold mb-6">Progress & Trends</h1>
|
||||||
|
|
||||||
|
<!-- Date Range Filter -->
|
||||||
|
<div class="mb-6 flex justify-end">
|
||||||
|
<form method="GET" action="/progress" class="bg-white rounded-lg shadow-sm border p-1 inline-flex">
|
||||||
|
<button type="submit" name="days" value="7" class="px-4 py-2 rounded-md text-sm font-medium transition <%= days === 7 ? 'bg-primary text-white' : 'text-gray-600 hover:text-primary' %>">7 Days</button>
|
||||||
|
<button type="submit" name="days" value="30" class="px-4 py-2 rounded-md text-sm font-medium transition <%= days === 30 ? 'bg-primary text-white' : 'text-gray-600 hover:text-primary' %>">30 Days</button>
|
||||||
|
<button type="submit" name="days" value="90" class="px-4 py-2 rounded-md text-sm font-medium transition <%= days === 90 ? 'bg-primary text-white' : 'text-gray-600 hover:text-primary' %>">90 Days</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="glass rounded-xl p-4 text-center">
|
||||||
|
<p class="text-gray-500 text-xs uppercase tracking-wide">Avg Calories</p>
|
||||||
|
<p class="text-2xl font-bold text-primary"><%= avg_calories %></p>
|
||||||
|
</div>
|
||||||
|
<div class="glass rounded-xl p-4 text-center">
|
||||||
|
<p class="text-gray-500 text-xs uppercase tracking-wide">Avg Protein</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-600"><%= avg_protein %>g</p>
|
||||||
|
</div>
|
||||||
|
<div class="glass rounded-xl p-4 text-center">
|
||||||
|
<p class="text-gray-500 text-xs uppercase tracking-wide">Avg Carbs</p>
|
||||||
|
<p class="text-2xl font-bold text-yellow-600"><%= avg_carbs %>g</p>
|
||||||
|
</div>
|
||||||
|
<div class="glass rounded-xl p-4 text-center">
|
||||||
|
<p class="text-gray-500 text-xs uppercase tracking-wide">Avg Fat</p>
|
||||||
|
<p class="text-2xl font-bold text-orange-600"><%= avg_fat %>g</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
<!-- Weight Chart -->
|
||||||
|
<div class="glass rounded-xl p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-lg">Weight Trend</h3>
|
||||||
|
<% if (weight_change !== null) { %>
|
||||||
|
<span class="text-sm font-medium <%= weight_change <= 0 ? 'text-green-600' : 'text-red-600' %>">
|
||||||
|
<%= weight_change > 0 ? '+' : '' %><%= weight_change.toFixed(1) %> kg
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="weightChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calories Chart -->
|
||||||
|
<div class="glass rounded-xl p-6">
|
||||||
|
<h3 class="font-bold text-lg mb-4">Calorie Intake</h3>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="calorieChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Weight Chart
|
||||||
|
const weightCtx = document.getElementById('weightChart').getContext('2d');
|
||||||
|
const weightData = <%- JSON.stringify(weight_trend) %>;
|
||||||
|
|
||||||
|
new Chart(weightCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: weightData.map(d => new Date(d.date).toLocaleDateString(undefined, {month:'short', day:'numeric'})),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Weight (kg)',
|
||||||
|
data: weightData.map(d => d.weight_kg),
|
||||||
|
borderColor: 'rgb(220, 38, 38)', // primary red
|
||||||
|
backgroundColor: 'rgba(220, 38, 38, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calorie Chart
|
||||||
|
const calorieCtx = document.getElementById('calorieChart').getContext('2d');
|
||||||
|
const calorieData = <%- JSON.stringify(calorie_trend) %>;
|
||||||
|
|
||||||
|
new Chart(calorieCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: calorieData.map(d => new Date(d.date).toLocaleDateString(undefined, {month:'short', day:'numeric'})),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Calories',
|
||||||
|
data: calorieData.map(d => d.calories),
|
||||||
|
backgroundColor: 'rgba(220, 38, 38, 0.7)',
|
||||||
|
borderRadius: 4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
30
views/register.ejs
Normal file
30
views/register.ejs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="glass rounded-xl p-8 shadow-lg">
|
||||||
|
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">Create Account</h1>
|
||||||
|
|
||||||
|
<form method="POST" action="/register">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||||
|
<input type="text" name="name" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||||
|
<input type="text" name="username" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||||
|
<input type="password" name="password" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full bg-primary text-white py-3 rounded-lg hover:bg-red-700 transition font-semibold">
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center mt-6 text-gray-600">
|
||||||
|
Already have an account? <a href="/login" class="text-primary hover:underline font-semibold">Login</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user