feat: add nutrition/food logging to fitness section
All checks were successful
CI / update (push) Successful in 4m47s
All checks were successful
CI / update (push) Successful in 4m47s
Daily food log with calorie and macro tracking against configurable diet goals (presets: WHO balanced, cut, bulk, keto, etc.). Includes USDA/BLS food search with portion-based units, favorite ingredients, custom reusable meals, per-food micronutrient detail pages, and recipe-to-log integration via AddToFoodLogButton. Extends FitnessGoal with nutrition targets and adds birth year to user profile for BMR calculation.
This commit is contained in:
79
src/models/CustomMeal.ts
Normal file
79
src/models/CustomMeal.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
interface ICustomMealIngredient {
|
||||
name: string;
|
||||
source: 'bls' | 'usda' | 'custom';
|
||||
sourceId?: string;
|
||||
amountGrams: number;
|
||||
portions?: { description: string; grams: number }[];
|
||||
selectedPortion?: { description: string; grams: number };
|
||||
per100g: {
|
||||
calories: number; protein: number; fat: number; saturatedFat: number;
|
||||
carbs: number; fiber: number; sugars: number;
|
||||
calcium: number; iron: number; magnesium: number; phosphorus: number;
|
||||
potassium: number; sodium: number; zinc: number;
|
||||
vitaminA: number; vitaminC: number; vitaminD: number; vitaminE: number;
|
||||
vitaminK: number; thiamin: number; riboflavin: number; niacin: number;
|
||||
vitaminB6: number; vitaminB12: number; folate: number; cholesterol: number;
|
||||
isoleucine?: number; leucine?: number; lysine?: number; methionine?: number;
|
||||
phenylalanine?: number; threonine?: number; tryptophan?: number; valine?: number;
|
||||
histidine?: number; alanine?: number; arginine?: number; asparticAcid?: number;
|
||||
cysteine?: number; glutamicAcid?: number; glycine?: number; proline?: number;
|
||||
serine?: number; tyrosine?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ICustomMeal {
|
||||
_id?: string;
|
||||
name: string;
|
||||
ingredients: ICustomMealIngredient[];
|
||||
createdBy: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const NutritionSnapshotSchema = new mongoose.Schema({
|
||||
calories: Number, protein: Number, fat: Number, saturatedFat: Number,
|
||||
carbs: Number, fiber: Number, sugars: Number,
|
||||
calcium: Number, iron: Number, magnesium: Number, phosphorus: Number,
|
||||
potassium: Number, sodium: Number, zinc: Number,
|
||||
vitaminA: Number, vitaminC: Number, vitaminD: Number, vitaminE: Number,
|
||||
vitaminK: Number, thiamin: Number, riboflavin: Number, niacin: Number,
|
||||
vitaminB6: Number, vitaminB12: Number, folate: Number, cholesterol: Number,
|
||||
isoleucine: Number, leucine: Number, lysine: Number, methionine: Number,
|
||||
phenylalanine: Number, threonine: Number, tryptophan: Number, valine: Number,
|
||||
histidine: Number, alanine: Number, arginine: Number, asparticAcid: Number,
|
||||
cysteine: Number, glutamicAcid: Number, glycine: Number, proline: Number,
|
||||
serine: Number, tyrosine: Number,
|
||||
}, { _id: false });
|
||||
|
||||
const PortionSchema = new mongoose.Schema({
|
||||
description: { type: String, required: true },
|
||||
grams: { type: Number, required: true },
|
||||
}, { _id: false });
|
||||
|
||||
const IngredientSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true, trim: true },
|
||||
source: { type: String, enum: ['bls', 'usda', 'custom'], required: true },
|
||||
sourceId: { type: String },
|
||||
amountGrams: { type: Number, required: true, min: 0 },
|
||||
portions: { type: [PortionSchema], default: undefined },
|
||||
selectedPortion: { type: PortionSchema, default: undefined },
|
||||
per100g: { type: NutritionSnapshotSchema, required: true },
|
||||
}, { _id: false });
|
||||
|
||||
const CustomMealSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true, trim: true },
|
||||
ingredients: { type: [IngredientSchema], required: true, validate: [(v: any[]) => v.length > 0, 'At least one ingredient is required'] },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
CustomMealSchema.index({ createdBy: 1 });
|
||||
|
||||
let _model: mongoose.Model<ICustomMeal>;
|
||||
try { _model = mongoose.model<ICustomMeal>('CustomMeal'); } catch { _model = mongoose.model<ICustomMeal>('CustomMeal', CustomMealSchema); }
|
||||
export const CustomMeal = _model;
|
||||
export type { ICustomMeal, ICustomMealIngredient };
|
||||
24
src/models/FavoriteIngredient.ts
Normal file
24
src/models/FavoriteIngredient.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const FavoriteIngredientSchema = new mongoose.Schema(
|
||||
{
|
||||
source: { type: String, enum: ['bls', 'usda'], required: true },
|
||||
sourceId: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
FavoriteIngredientSchema.index({ createdBy: 1, source: 1, sourceId: 1 }, { unique: true });
|
||||
|
||||
interface IFavoriteIngredient {
|
||||
source: 'bls' | 'usda';
|
||||
sourceId: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
let _model: mongoose.Model<IFavoriteIngredient>;
|
||||
try { _model = mongoose.model<IFavoriteIngredient>("FavoriteIngredient"); } catch { _model = mongoose.model<IFavoriteIngredient>("FavoriteIngredient", FavoriteIngredientSchema); }
|
||||
export const FavoriteIngredient = _model;
|
||||
@@ -5,7 +5,14 @@ const FitnessGoalSchema = new mongoose.Schema(
|
||||
username: { type: String, required: true, unique: true },
|
||||
weeklyWorkouts: { type: Number, required: true, default: 4, min: 1, max: 14 },
|
||||
sex: { type: String, enum: ['male', 'female'], default: 'male' },
|
||||
heightCm: { type: Number, min: 100, max: 250 }
|
||||
heightCm: { type: Number, min: 100, max: 250 },
|
||||
birthYear: { type: Number, min: 1900, max: 2020 },
|
||||
activityLevel: { type: String, enum: ['sedentary', 'light', 'moderate', 'very_active'], default: 'light' },
|
||||
dailyCalories: { type: Number, min: 500, max: 10000 },
|
||||
proteinMode: { type: String, enum: ['fixed', 'per_kg'] },
|
||||
proteinTarget: { type: Number, min: 0 },
|
||||
fatPercent: { type: Number, min: 0, max: 100 },
|
||||
carbPercent: { type: Number, min: 0, max: 100 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
@@ -15,6 +22,13 @@ interface IFitnessGoal {
|
||||
weeklyWorkouts: number;
|
||||
sex?: 'male' | 'female';
|
||||
heightCm?: number;
|
||||
birthYear?: number;
|
||||
activityLevel?: 'sedentary' | 'light' | 'moderate' | 'very_active';
|
||||
dailyCalories?: number;
|
||||
proteinMode?: 'fixed' | 'per_kg';
|
||||
proteinTarget?: number;
|
||||
fatPercent?: number;
|
||||
carbPercent?: number;
|
||||
}
|
||||
|
||||
let _model: mongoose.Model<IFitnessGoal>;
|
||||
|
||||
64
src/models/FoodLogEntry.ts
Normal file
64
src/models/FoodLogEntry.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
interface IFoodLogEntry {
|
||||
_id?: string;
|
||||
date: Date;
|
||||
mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
name: string;
|
||||
source: 'bls' | 'usda' | 'recipe' | 'custom';
|
||||
sourceId?: string;
|
||||
amountGrams: number;
|
||||
per100g: {
|
||||
calories: number; protein: number; fat: number; saturatedFat: number;
|
||||
carbs: number; fiber: number; sugars: number;
|
||||
calcium: number; iron: number; magnesium: number; phosphorus: number;
|
||||
potassium: number; sodium: number; zinc: number;
|
||||
vitaminA: number; vitaminC: number; vitaminD: number; vitaminE: number;
|
||||
vitaminK: number; thiamin: number; riboflavin: number; niacin: number;
|
||||
vitaminB6: number; vitaminB12: number; folate: number; cholesterol: number;
|
||||
isoleucine?: number; leucine?: number; lysine?: number; methionine?: number;
|
||||
phenylalanine?: number; threonine?: number; tryptophan?: number; valine?: number;
|
||||
histidine?: number; alanine?: number; arginine?: number; asparticAcid?: number;
|
||||
cysteine?: number; glutamicAcid?: number; glycine?: number; proline?: number;
|
||||
serine?: number; tyrosine?: number;
|
||||
};
|
||||
createdBy: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const NutritionSnapshotSchema = new mongoose.Schema({
|
||||
calories: Number, protein: Number, fat: Number, saturatedFat: Number,
|
||||
carbs: Number, fiber: Number, sugars: Number,
|
||||
calcium: Number, iron: Number, magnesium: Number, phosphorus: Number,
|
||||
potassium: Number, sodium: Number, zinc: Number,
|
||||
vitaminA: Number, vitaminC: Number, vitaminD: Number, vitaminE: Number,
|
||||
vitaminK: Number, thiamin: Number, riboflavin: Number, niacin: Number,
|
||||
vitaminB6: Number, vitaminB12: Number, folate: Number, cholesterol: Number,
|
||||
isoleucine: Number, leucine: Number, lysine: Number, methionine: Number,
|
||||
phenylalanine: Number, threonine: Number, tryptophan: Number, valine: Number,
|
||||
histidine: Number, alanine: Number, arginine: Number, asparticAcid: Number,
|
||||
cysteine: Number, glutamicAcid: Number, glycine: Number, proline: Number,
|
||||
serine: Number, tyrosine: Number,
|
||||
}, { _id: false });
|
||||
|
||||
const FoodLogEntrySchema = new mongoose.Schema(
|
||||
{
|
||||
date: { type: Date, required: true },
|
||||
mealType: { type: String, enum: ['breakfast', 'lunch', 'dinner', 'snack'], required: true },
|
||||
name: { type: String, required: true, trim: true },
|
||||
source: { type: String, enum: ['bls', 'usda', 'recipe', 'custom'], required: true },
|
||||
sourceId: { type: String },
|
||||
amountGrams: { type: Number, required: true, min: 0 },
|
||||
per100g: { type: NutritionSnapshotSchema, required: true },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
FoodLogEntrySchema.index({ createdBy: 1, date: -1 });
|
||||
|
||||
let _model: mongoose.Model<IFoodLogEntry>;
|
||||
try { _model = mongoose.model<IFoodLogEntry>('FoodLogEntry'); } catch { _model = mongoose.model<IFoodLogEntry>('FoodLogEntry', FoodLogEntrySchema); }
|
||||
export const FoodLogEntry = _model;
|
||||
export type { IFoodLogEntry };
|
||||
Reference in New Issue
Block a user