Files
Arnaud 9566e19cd0 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.
2026-07-12 18:35:58 +02:00

138 lines
5.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
import { getMessages, formatMessage } from "@/lib/i18n/server";
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 {
// 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,
}: {
searchParams: Promise<{ week?: string }>;
}) {
const { week } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const monday = getMonday(week);
const weekStart = toDateStr(monday);
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: { entries: { with: { recipe: true } } },
});
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
const entries = plan?.entries ?? [];
return (
<>
<style>{`
@media print {
body { margin: 0; }
.no-print { display: none !important; }
}
body {
font-family: system-ui, sans-serif;
color: #1a1a1a;
max-width: 900px;
margin: 40px auto;
padding: 0 24px;
font-size: 13px;
line-height: 1.5;
}
h1 { font-size: 1.8em; margin: 0 0 2px; }
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 6px 8px; border-bottom: 2px solid #ccc; }
td { padding: 6px 8px; border-bottom: 1px solid #eee; border-right: 1px solid #eee; vertical-align: top; min-height: 40px; }
td:last-child { border-right: none; }
.meal-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; display: block; margin-bottom: 2px; }
.recipe-title { font-weight: 500; }
.servings { font-size: 0.8em; color: #888; }
.empty { color: #ccc; font-size: 0.85em; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
.print-btn {
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
background: #18181b; color: white; border: none; border-radius: 6px;
cursor: pointer; font-size: 13px;
}
.print-btn:hover { background: #3f3f46; }
`}</style>
<PrintTrigger />
<h1>{m.mealPlan.title}</h1>
<p className="subtitle">{label}</p>
<table>
<thead>
<tr>
<th style={{ width: "100px" }}>{m.print.day}</th>
{MEAL_ORDER.map((meal) => (
<th key={meal}>{m.mealPlan.meals[meal]}</th>
))}
</tr>
</thead>
<tbody>
{DAYS.map((day) => (
<tr key={day}>
<td style={{ fontWeight: 600, color: "#555" }}>{m.print.days[day]}</td>
{MEAL_ORDER.map((mealType) => {
const entry = entries.find((e) => e.day === day && e.mealType === mealType);
return (
<td key={mealType}>
{entry ? (
<>
<span className="recipe-title">{entry.recipe?.title ?? entry.note ?? "—"}</span>
{entry.servings && (
<span className="servings">{formatMessage(m.print.srv, { count: entry.servings })}</span>
)}
</>
) : (
<span className="empty"></span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<footer>{m.print.footer}</footer>
</>
);
}