feat: add nutrition/food logging to fitness section

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 5d7a959355
commit 23b45abc5a
27 changed files with 4904 additions and 20 deletions
@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { CustomMeal } from '$models/CustomMeal';
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const meals = await CustomMeal.find({ createdBy: user.nickname }).sort({ updatedAt: -1 }).lean();
return json({ meals });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const body = await request.json();
const { name, ingredients } = body;
if (!name?.trim()) throw error(400, 'name is required');
if (!Array.isArray(ingredients) || ingredients.length === 0) throw error(400, 'At least one ingredient is required');
const meal = await CustomMeal.create({
name: name.trim(),
ingredients,
createdBy: user.nickname,
});
return json(meal.toObject(), { status: 201 });
};
@@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { CustomMeal } from '$models/CustomMeal';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const meal = await CustomMeal.findById(params.id);
if (!meal) throw error(404, 'Meal not found');
if (meal.createdBy !== user.nickname) throw error(403, 'Not authorized');
const body = await request.json();
if (body.name !== undefined) {
if (!body.name?.trim()) throw error(400, 'name cannot be empty');
meal.name = body.name.trim();
}
if (body.ingredients !== undefined) {
if (!Array.isArray(body.ingredients) || body.ingredients.length === 0) throw error(400, 'At least one ingredient is required');
meal.ingredients = body.ingredients;
}
await meal.save();
return json(meal.toObject());
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const deleted = await CustomMeal.findOneAndDelete({
_id: params.id,
createdBy: user.nickname,
});
if (!deleted) throw error(404, 'Meal not found');
return json({ ok: true });
};
@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { FavoriteIngredient } from '$models/FavoriteIngredient';
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const favorites = await FavoriteIngredient.find({ createdBy: user.nickname }).lean();
return json({ favorites });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
const { source, sourceId, name } = await request.json();
if (!source || !sourceId || !name) {
return json({ error: 'source, sourceId, and name are required' }, { status: 400 });
}
if (source !== 'bls' && source !== 'usda') {
return json({ error: 'source must be "bls" or "usda"' }, { status: 400 });
}
await dbConnect();
await FavoriteIngredient.findOneAndUpdate(
{ createdBy: user.nickname, source, sourceId: String(sourceId) },
{ createdBy: user.nickname, source, sourceId: String(sourceId), name },
{ upsert: true, new: true }
);
return json({ ok: true }, { status: 201 });
};
export const DELETE: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
const { source, sourceId } = await request.json();
if (!source || !sourceId) {
return json({ error: 'source and sourceId are required' }, { status: 400 });
}
await dbConnect();
await FavoriteIngredient.deleteOne({
createdBy: user.nickname,
source,
sourceId: String(sourceId),
});
return json({ ok: true });
};
@@ -0,0 +1,62 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { FoodLogEntry } from '$models/FoodLogEntry';
const VALID_MEALS = ['breakfast', 'lunch', 'dinner', 'snack'];
export const GET: RequestHandler = async ({ locals, url }) => {
const user = await requireAuth(locals);
await dbConnect();
const dateParam = url.searchParams.get('date');
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
const query: Record<string, any> = { createdBy: user.nickname };
if (dateParam) {
const d = new Date(dateParam + 'T00:00:00.000Z');
const next = new Date(d);
next.setUTCDate(next.getUTCDate() + 1);
query.date = { $gte: d, $lt: next };
} else if (from || to) {
query.date = {};
if (from) query.date.$gte = new Date(from + 'T00:00:00.000Z');
if (to) {
const t = new Date(to + 'T00:00:00.000Z');
t.setUTCDate(t.getUTCDate() + 1);
query.date.$lt = t;
}
}
const entries = await FoodLogEntry.find(query).sort({ date: -1, mealType: 1 }).lean();
return json({ entries });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const body = await request.json();
const { date, mealType, name, source, sourceId, amountGrams, per100g } = body;
if (!date || !name?.trim()) throw error(400, 'date and name are required');
if (!VALID_MEALS.includes(mealType)) throw error(400, 'Invalid mealType');
if (typeof amountGrams !== 'number' || amountGrams <= 0) throw error(400, 'amountGrams must be positive');
if (!per100g || typeof per100g.calories !== 'number') throw error(400, 'per100g with calories is required');
const entry = await FoodLogEntry.create({
date: new Date(date + 'T00:00:00.000Z'),
mealType,
name: name.trim(),
source: source || 'custom',
sourceId,
amountGrams,
per100g,
createdBy: user.nickname,
});
return json(entry.toObject(), { status: 201 });
};
@@ -0,0 +1,35 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { FoodLogEntry } from '$models/FoodLogEntry';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const entry = await FoodLogEntry.findById(params.id);
if (!entry) throw error(404, 'Entry not found');
if (entry.createdBy !== user.nickname) throw error(403, 'Not authorized');
const body = await request.json();
const allowed = ['amountGrams', 'mealType', 'name'] as const;
for (const key of allowed) {
if (body[key] !== undefined) (entry as any)[key] = body[key];
}
await entry.save();
return json(entry.toObject());
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const deleted = await FoodLogEntry.findOneAndDelete({
_id: params.id,
createdBy: user.nickname,
});
if (!deleted) throw error(404, 'Entry not found');
return json({ ok: true });
};
+29 -3
View File
@@ -12,13 +12,22 @@ export const GET: RequestHandler = async ({ locals }) => {
const goal = await FitnessGoal.findOne({ username: user.nickname }).lean() as any;
const weeklyWorkouts = goal?.weeklyWorkouts ?? null;
const nutritionGoals = {
activityLevel: goal?.activityLevel ?? 'light',
dailyCalories: goal?.dailyCalories ?? null,
proteinMode: goal?.proteinMode ?? null,
proteinTarget: goal?.proteinTarget ?? null,
fatPercent: goal?.fatPercent ?? null,
carbPercent: goal?.carbPercent ?? null,
};
// If no goal set, return early
if (weeklyWorkouts === null) {
return json({ weeklyWorkouts: null, streak: 0, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null });
return json({ weeklyWorkouts: null, streak: 0, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null, birthYear: goal?.birthYear ?? null, ...nutritionGoals });
}
const streak = await computeStreak(user.nickname, weeklyWorkouts);
return json({ weeklyWorkouts, streak, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null });
return json({ weeklyWorkouts, streak, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null, birthYear: goal?.birthYear ?? null, ...nutritionGoals });
};
export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -33,6 +42,14 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
const update: Record<string, unknown> = { weeklyWorkouts };
if (sex === 'male' || sex === 'female') update.sex = sex;
if (typeof heightCm === 'number' && heightCm >= 100 && heightCm <= 250) update.heightCm = heightCm;
if (typeof body.birthYear === 'number' && body.birthYear >= 1900 && body.birthYear <= 2020) update.birthYear = body.birthYear;
const validActivity = ['sedentary', 'light', 'moderate', 'very_active'];
if (validActivity.includes(body.activityLevel)) update.activityLevel = body.activityLevel;
if (typeof body.dailyCalories === 'number' && body.dailyCalories >= 500 && body.dailyCalories <= 10000) update.dailyCalories = body.dailyCalories;
if (body.proteinMode === 'fixed' || body.proteinMode === 'per_kg') update.proteinMode = body.proteinMode;
if (typeof body.proteinTarget === 'number' && body.proteinTarget >= 0) update.proteinTarget = body.proteinTarget;
if (typeof body.fatPercent === 'number' && body.fatPercent >= 0 && body.fatPercent <= 100) update.fatPercent = body.fatPercent;
if (typeof body.carbPercent === 'number' && body.carbPercent >= 0 && body.carbPercent <= 100) update.carbPercent = body.carbPercent;
await dbConnect();
@@ -43,7 +60,16 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
).lean() as any;
const streak = await computeStreak(user.nickname, weeklyWorkouts);
return json({ weeklyWorkouts, streak, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null });
return json({
weeklyWorkouts, streak,
sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null, birthYear: goal?.birthYear ?? null,
activityLevel: goal?.activityLevel ?? 'light',
dailyCalories: goal?.dailyCalories ?? null,
proteinMode: goal?.proteinMode ?? null,
proteinTarget: goal?.proteinTarget ?? null,
fatPercent: goal?.fatPercent ?? null,
carbPercent: goal?.carbPercent ?? null,
});
};
async function computeStreak(username: string, weeklyGoal: number): Promise<number> {
+76 -3
View File
@@ -2,13 +2,73 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import { NUTRITION_DB } from '$lib/data/nutritionDb';
import { BLS_DB } from '$lib/data/blsDb';
import { fuzzyScore } from '$lib/js/fuzzy';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { FavoriteIngredient } from '$models/FavoriteIngredient';
type SearchResult = { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; favorited?: boolean; per100g?: any; portions?: any[] };
function lookupBls(blsCode: string, full: boolean): SearchResult | null {
const entry = BLS_DB.find(e => e.blsCode === blsCode);
if (!entry) return null;
return {
source: 'bls',
id: entry.blsCode,
name: `${entry.nameDe}${entry.nameEn ? ` (${entry.nameEn})` : ''}`,
category: entry.category,
calories: entry.per100g.calories,
...(full && { per100g: entry.per100g }),
};
}
function lookupUsda(fdcId: string, full: boolean): SearchResult | null {
const entry = NUTRITION_DB.find(e => String(e.fdcId) === fdcId);
if (!entry) return null;
return {
source: 'usda',
id: String(entry.fdcId),
name: entry.name,
category: entry.category,
calories: entry.per100g.calories,
...(full && { per100g: entry.per100g, portions: entry.portions }),
};
}
/** GET: Search BLS + USDA nutrition databases by fuzzy name match */
export const GET: RequestHandler = async ({ url }) => {
export const GET: RequestHandler = async ({ url, locals }) => {
const q = (url.searchParams.get('q') || '').toLowerCase().trim();
if (q.length < 2) return json([]);
const scored: { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; score: number }[] = [];
const full = url.searchParams.get('full') === 'true';
const wantFavorites = url.searchParams.get('favorites') === 'true';
// Optionally load user favorites
let favResults: SearchResult[] = [];
let favKeys = new Set<string>();
if (wantFavorites) {
try {
const user = await requireAuth(locals);
await dbConnect();
const favDocs = await FavoriteIngredient.find({ createdBy: user.nickname }).lean();
for (const fav of favDocs) {
const key = `${fav.source}:${fav.sourceId}`;
const result = fav.source === 'bls'
? lookupBls(fav.sourceId, full)
: lookupUsda(fav.sourceId, full);
if (result) {
result.favorited = true;
favResults.push(result);
favKeys.add(key);
}
}
} catch {
// Not authenticated or DB error — ignore, just return normal results
}
}
const scored: (SearchResult & { score: number })[] = [];
// Search BLS (primary)
for (const entry of BLS_DB) {
@@ -23,6 +83,8 @@ export const GET: RequestHandler = async ({ url }) => {
category: entry.category,
calories: entry.per100g.calories,
score: best,
...(full && { per100g: entry.per100g }),
...(favKeys.has(`bls:${entry.blsCode}`) && { favorited: true }),
});
}
}
@@ -38,11 +100,22 @@ export const GET: RequestHandler = async ({ url }) => {
category: entry.category,
calories: entry.per100g.calories,
score: s,
...(full && { per100g: entry.per100g, portions: entry.portions }),
...(favKeys.has(`usda:${entry.fdcId}`) && { favorited: true }),
});
}
}
// Sort by score descending, return top 30 (without score field)
scored.sort((a, b) => b.score - a.score);
return json(scored.slice(0, 30).map(({ score, ...rest }) => rest));
const searchResults = scored.slice(0, 30).map(({ score, ...rest }) => rest);
// Prepend favorites, deduplicating
if (favResults.length > 0) {
const searchKeys = new Set(searchResults.map(r => `${r.source}:${r.id}`));
const uniqueFavs = favResults.filter(f => !searchKeys.has(`${f.source}:${f.id}`));
return json([...uniqueFavs, ...searchResults]);
}
return json(searchResults);
};