9566e19cd0
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.
172 lines
7.0 KiB
TypeScript
172 lines
7.0 KiB
TypeScript
import type { Metadata } from "next";
|
||
import { headers } from "next/headers";
|
||
import Link from "next/link";
|
||
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
|
||
import { auth } from "@/lib/auth/server";
|
||
import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db";
|
||
import { buttonVariants } from "@/components/ui/button";
|
||
import { MealPlanner } from "@/components/meal-plan/meal-planner";
|
||
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
|
||
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
|
||
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
|
||
import { cn } from "@/lib/utils";
|
||
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
||
import { mealPlanToMarkdown } from "@/lib/markdown/meal-plan";
|
||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||
|
||
export const metadata: Metadata = {};
|
||
|
||
function getMonday(dateStr?: string): Date {
|
||
// 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 {
|
||
// 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 {
|
||
const copy = new Date(d);
|
||
copy.setDate(copy.getDate() + n * 7);
|
||
return copy;
|
||
}
|
||
|
||
export default async function MealPlanPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<{ week?: string }>;
|
||
}) {
|
||
const { week } = await searchParams;
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return null;
|
||
const msgs = getMessages((session.user as { locale?: string }).locale);
|
||
|
||
const monday = getMonday(week);
|
||
const weekStart = toDateStr(monday);
|
||
const prevWeek = toDateStr(addWeeks(monday, -1));
|
||
const nextWeek = toDateStr(addWeeks(monday, 1));
|
||
|
||
const sunday = addWeeks(monday, 1);
|
||
sunday.setDate(sunday.getDate() - 1);
|
||
|
||
const [plan, userRecipes, sharedMemberships, nutritionGoals] = await Promise.all([
|
||
db.query.mealPlans.findFirst({
|
||
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
|
||
with: {
|
||
entries: {
|
||
with: { recipe: { with: { photos: true } }, batchDish: true },
|
||
},
|
||
},
|
||
}),
|
||
db.query.recipes.findMany({
|
||
where: eq(recipes.authorId, session.user.id),
|
||
orderBy: desc(recipes.updatedAt),
|
||
columns: { id: true, title: true, isBatchCook: true },
|
||
with: { batchDishes: { columns: { id: true, name: true }, orderBy: (t, { asc }) => asc(t.order) } },
|
||
}),
|
||
db.query.mealPlanMembers.findMany({
|
||
where: eq(mealPlanMembers.userId, session.user.id),
|
||
with: { mealPlan: { with: { user: true } } },
|
||
}),
|
||
db.query.userNutritionGoals.findFirst({
|
||
where: eq(userNutritionGoals.userId, session.user.id),
|
||
}),
|
||
]);
|
||
|
||
const hasNutritionGoals = !!(
|
||
nutritionGoals &&
|
||
(nutritionGoals.caloriesKcal || nutritionGoals.proteinG || nutritionGoals.carbsG || nutritionGoals.fatG)
|
||
);
|
||
|
||
const entries = (plan?.entries ?? []).map((e) => ({
|
||
id: e.id,
|
||
day: e.day,
|
||
mealType: e.mealType,
|
||
servings: e.servings,
|
||
note: e.note,
|
||
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
|
||
batchDish: e.batchDish ? { id: e.batchDish.id, name: e.batchDish.name } : null,
|
||
}));
|
||
|
||
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold tracking-tight">{msgs.mealPlan.title}</h1>
|
||
<p className="text-muted-foreground mt-1">{label}</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<ShareMealPlanButton weekStart={weekStart} />
|
||
<NewShoppingListButton
|
||
defaultWeekStart={weekStart}
|
||
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
|
||
/>
|
||
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||
<ShoppingCart className="h-4 w-4" />
|
||
{msgs.mealPlan.shoppingLists}
|
||
</Link>
|
||
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||
<Printer className="h-4 w-4" />
|
||
{msgs.common.print}
|
||
</Link>
|
||
<ExportMarkdownButton
|
||
markdown={mealPlanToMarkdown({ label, entries })}
|
||
filename={`meal-plan-${weekStart}`}
|
||
/>
|
||
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Link>
|
||
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<WeeklyNutritionBar weekStart={weekStart} />
|
||
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
|
||
|
||
{sharedMemberships.length > 0 && (
|
||
<div className="space-y-3">
|
||
<h2 className="text-sm font-semibold text-muted-foreground">{msgs.mealPlan.sharedWithYou}</h2>
|
||
<div className="space-y-2 max-w-lg">
|
||
{sharedMemberships.map((membership) => (
|
||
<Link
|
||
key={membership.id}
|
||
href={`/meal-plan/shared/${membership.mealPlan.id}`}
|
||
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||
>
|
||
<div>
|
||
<p className="font-medium">{formatMessage(msgs.mealPlan.sharedPlanOf, { name: membership.mealPlan.user?.name ?? "?" })}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{formatMessage(msgs.mealPlan.weekOf, { date: membership.mealPlan.weekStart })} · {msgs.shareDialog[membership.role]}
|
||
</p>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|