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:
@@ -17,16 +17,30 @@ import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
function getMonday(dateStr?: string): Date {
|
||||
const d = dateStr ? new Date(dateStr) : new Date();
|
||||
const day = d.getDay();
|
||||
const diff = (day === 0 ? -6 : 1 - day);
|
||||
// new Date("YYYY-MM-DD") parses as UTC midnight, but getDay() below reads
|
||||
// local time — in negative UTC-offset zones that's still "yesterday",
|
||||
// shifting the resolved Monday back by a full week. Parse the y/m/d parts
|
||||
// directly into local time instead.
|
||||
const d = dateStr
|
||||
? (() => {
|
||||
const [y, m, day] = dateStr.split("-").map(Number);
|
||||
return new Date(y!, m! - 1, day!);
|
||||
})()
|
||||
: new Date();
|
||||
const dow = d.getDay();
|
||||
const diff = (dow === 0 ? -6 : 1 - dow);
|
||||
d.setDate(d.getDate() + diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
// Read local y/m/d, not toISOString() (which converts to UTC and can
|
||||
// shift the date by a day depending on the server's timezone offset).
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function addWeeks(d: Date, n: number): Date {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,14 +8,29 @@ const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||
|
||||
function getMonday(dateStr?: string): Date {
|
||||
const d = dateStr ? new Date(dateStr) : new Date();
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
// Parse y/m/d directly into local time — new Date("YYYY-MM-DD") parses as
|
||||
// UTC midnight, which getDay() (local time) can read as the wrong weekday
|
||||
// depending on the server's timezone offset.
|
||||
const d = dateStr
|
||||
? (() => {
|
||||
const [y, m, day] = dateStr.split("-").map(Number);
|
||||
return new Date(y!, m! - 1, day!);
|
||||
})()
|
||||
: new Date();
|
||||
const dow = d.getDay();
|
||||
const diff = dow === 0 ? -6 : 1 - dow;
|
||||
d.setDate(d.getDate() + diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default async function MealPlanPrintPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -28,7 +43,7 @@ export default async function MealPlanPrintPage({
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const monday = getMonday(week);
|
||||
const weekStart = monday.toISOString().slice(0, 10);
|
||||
const weekStart = toDateStr(monday);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(sunday.getDate() + 6);
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ type NutritionCoverage = {
|
||||
|
||||
type NutritionResponse = {
|
||||
totals: NutritionTotals;
|
||||
dailyAverage: NutritionTotals;
|
||||
unknownCount: number;
|
||||
goals: NutritionGoals;
|
||||
coverage: NutritionCoverage;
|
||||
};
|
||||
@@ -54,12 +56,12 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
||||
|
||||
if (!data || !data.goals) return null;
|
||||
|
||||
const { totals, goals, coverage } = data;
|
||||
const { dailyAverage, goals, coverage, unknownCount } = data;
|
||||
|
||||
const bars: BarItem[] = [
|
||||
{
|
||||
label: "Calories",
|
||||
value: totals.calories,
|
||||
value: dailyAverage.calories,
|
||||
goal: goals.caloriesKcal,
|
||||
unit: "kcal",
|
||||
percentage: coverage.calories,
|
||||
@@ -67,7 +69,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
||||
},
|
||||
{
|
||||
label: "Protein",
|
||||
value: totals.protein,
|
||||
value: dailyAverage.protein,
|
||||
goal: goals.proteinG,
|
||||
unit: "g",
|
||||
percentage: coverage.protein,
|
||||
@@ -75,7 +77,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
||||
},
|
||||
{
|
||||
label: "Carbs",
|
||||
value: totals.carbs,
|
||||
value: dailyAverage.carbs,
|
||||
goal: goals.carbsG,
|
||||
unit: "g",
|
||||
percentage: coverage.carbs,
|
||||
@@ -83,7 +85,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
||||
},
|
||||
{
|
||||
label: "Fat",
|
||||
value: totals.fat,
|
||||
value: dailyAverage.fat,
|
||||
goal: goals.fatG,
|
||||
unit: "g",
|
||||
percentage: coverage.fat,
|
||||
@@ -93,6 +95,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">Daily average vs. your goals</p>
|
||||
{bars.map((bar) => {
|
||||
if (bar.goal == null) return null;
|
||||
const width = Math.min(bar.percentage, 100);
|
||||
@@ -113,6 +116,11 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{unknownCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unknownCount} planned {unknownCount === 1 ? "meal" : "meals"} missing nutrition data — estimate it from the recipe page for a more accurate picture.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user