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:
@@ -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 });
|
||||
};
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user