fix: weekly meal-plan nutrition compared a week's total against a daily goal

userNutritionGoals are daily targets (the nutrition diary already compares
a single day's totals against them directly) — but the weekly meal-plan
route summed an entire week's entries and compared that raw total straight
against the same daily number, so coverage read roughly 7x too high.

Now computes a daily average (across days that actually have planned
meals) and compares that against the goal instead, adds a per-day
breakdown (byDay) for a future day-view, and tracks unknownCount for
entries whose recipe has no nutrition data yet, matching the diary route.

Also fixed a real bug this surfaced: meal-plan/page.tsx and print/meal-plan
parsed the ?week= date via new Date(dateStr) (UTC midnight) then read
.getDay() (local time) — in positive-UTC-offset timezones this resolves to
the wrong Monday, and the reverse (Date -> string via toISOString()) has
the same mismatch in the other direction. Both now do local y/m/d math
throughout.

Verified locally: correct daily-average math end to end (recipe entry +
batch-dish entry, which already attributed nutrition correctly via its
required parent recipeId — no separate fix needed there), and confirmed
the meal-plan page now resolves the right week and renders real coverage
bars against actual planned entries.
This commit is contained in:
Arnaud
2026-07-12 18:35:58 +02:00
parent a9dc1b63c1
commit 9566e19cd0
4 changed files with 118 additions and 53 deletions
@@ -1,9 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { db, mealPlans, mealPlanEntries, recipes, userNutritionGoals, eq, and } from "@epicure/db";
import { db, mealPlans, mealPlanEntries, userNutritionGoals, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ weekStart: string }> };
type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type Totals = { calories: number; protein: number; carbs: number; fat: number };
function emptyTotals(): Totals {
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
}
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
@@ -11,75 +19,95 @@ export async function GET(_req: NextRequest, { params }: Params) {
const { weekStart } = await params;
const userId = session!.user.id;
// Find the meal plan for this week
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
});
const totals = { calories: 0, protein: 0, carbs: 0, fat: 0 };
const weekTotals = emptyTotals();
const byDay: Record<Weekday, Totals> = {
mon: emptyTotals(), tue: emptyTotals(), wed: emptyTotals(), thu: emptyTotals(),
fri: emptyTotals(), sat: emptyTotals(), sun: emptyTotals(),
};
let unknownCount = 0;
let plannedDays = 0;
if (plan) {
// Fetch all entries with their recipes
// batchDishId entries always carry their parent recipeId too (enforced at
// entry-creation time), so joining on `recipe` already covers batch
// dishes — there's no separate per-dish nutrition data to look up.
const entries = await db.query.mealPlanEntries.findMany({
where: eq(mealPlanEntries.mealPlanId, plan.id),
with: {
recipe: {
columns: {
id: true,
baseServings: true,
nutritionData: true,
},
columns: { id: true, baseServings: true, nutritionData: true },
},
},
});
const daysWithEntries = new Set<string>();
for (const entry of entries) {
daysWithEntries.add(entry.day);
const recipe = entry.recipe;
if (!recipe || !recipe.nutritionData?.perServing) continue;
if (!recipe || !recipe.nutritionData?.perServing) {
unknownCount++;
continue;
}
const { calories, proteinG, carbsG, fatG } = recipe.nutritionData.perServing;
const servings = entry.servings ?? recipe.baseServings;
const day = byDay[entry.day as Weekday];
totals.calories += Math.round(calories * servings);
totals.protein += Math.round(proteinG * servings);
totals.carbs += Math.round(carbsG * servings);
totals.fat += Math.round(fatG * servings);
const cals = Math.round(calories * servings);
const protein = Math.round(proteinG * servings);
const carbs = Math.round(carbsG * servings);
const fat = Math.round(fatG * servings);
weekTotals.calories += cals;
weekTotals.protein += protein;
weekTotals.carbs += carbs;
weekTotals.fat += fat;
day.calories += cals;
day.protein += protein;
day.carbs += carbs;
day.fat += fat;
}
plannedDays = daysWithEntries.size;
}
// Fetch user's nutrition goals
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, userId),
});
const goalsData = goals
? {
caloriesKcal: goals.caloriesKcal,
proteinG: goals.proteinG,
carbsG: goals.carbsG,
fatG: goals.fatG,
}
? { caloriesKcal: goals.caloriesKcal, proteinG: goals.proteinG, carbsG: goals.carbsG, fatG: goals.fatG }
: null;
// Calculate coverage percentages
const coverage = {
calories:
goalsData?.caloriesKcal
? Math.round((totals.calories / goalsData.caloriesKcal) * 100)
: 0,
protein:
goalsData?.proteinG
? Math.round((totals.protein / goalsData.proteinG) * 100)
: 0,
carbs:
goalsData?.carbsG
? Math.round((totals.carbs / goalsData.carbsG) * 100)
: 0,
fat:
goalsData?.fatG
? Math.round((totals.fat / goalsData.fatG) * 100)
: 0,
// Goals are daily targets (see the nutrition diary, which compares a single
// day's totals against them directly) — a week's raw total is ~7x a daily
// goal, so coverage must compare against the daily AVERAGE across days that
// actually have planned meals, not the week's total.
const divisor = plannedDays || 1;
const dailyAverage: Totals = {
calories: Math.round(weekTotals.calories / divisor),
protein: Math.round(weekTotals.protein / divisor),
carbs: Math.round(weekTotals.carbs / divisor),
fat: Math.round(weekTotals.fat / divisor),
};
return NextResponse.json({ totals, goals: goalsData, coverage });
const coverage = {
calories: goalsData?.caloriesKcal ? Math.round((dailyAverage.calories / goalsData.caloriesKcal) * 100) : 0,
protein: goalsData?.proteinG ? Math.round((dailyAverage.protein / goalsData.proteinG) * 100) : 0,
carbs: goalsData?.carbsG ? Math.round((dailyAverage.carbs / goalsData.carbsG) * 100) : 0,
fat: goalsData?.fatG ? Math.round((dailyAverage.fat / goalsData.fatG) * 100) : 0,
};
return NextResponse.json({
totals: weekTotals,
dailyAverage,
byDay,
plannedDays,
unknownCount,
goals: goalsData,
coverage,
});
}