feat: add nutrition/food logging to fitness section
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:
2026-04-04 14:34:45 +02:00
parent 4a0cddf4b7
commit c4420b73d2
27 changed files with 4904 additions and 20 deletions

79
src/models/CustomMeal.ts Normal file
View 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 };

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

View File

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

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