From 9566e19cd06f5a2f53373533fc3b89caf2f622af Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 18:35:58 +0200 Subject: [PATCH] fix: weekly meal-plan nutrition compared a week's total against a daily goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/app/(app)/meal-plan/page.tsx | 22 +++- .../meal-plans/[weekStart]/nutrition/route.ts | 108 +++++++++++------- apps/web/app/print/meal-plan/page.tsx | 23 +++- .../nutrition/weekly-nutrition-bar.tsx | 18 ++- 4 files changed, 118 insertions(+), 53 deletions(-) diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 6a94247..1860be8 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -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 { diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts index 0aadb78..924448f 100644 --- a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts @@ -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 = { + 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(); 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, + }); } diff --git a/apps/web/app/print/meal-plan/page.tsx b/apps/web/app/print/meal-plan/page.tsx index 1bd3da5..c206fce 100644 --- a/apps/web/app/print/meal-plan/page.tsx +++ b/apps/web/app/print/meal-plan/page.tsx @@ -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); diff --git a/apps/web/components/nutrition/weekly-nutrition-bar.tsx b/apps/web/components/nutrition/weekly-nutrition-bar.tsx index 0353061..91df563 100644 --- a/apps/web/components/nutrition/weekly-nutrition-bar.tsx +++ b/apps/web/components/nutrition/weekly-nutrition-bar.tsx @@ -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 (
+

Daily average vs. your goals

{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) {
); })} + {unknownCount > 0 && ( +

+ {unknownCount} planned {unknownCount === 1 ? "meal" : "meals"} missing nutrition data — estimate it from the recipe page for a more accurate picture. +

+ )} ); }