Files
Epicure/apps/web/app/(app)/meal-plan/page.tsx
T
Arnaud 45adb023b9 feat: icon+tooltip buttons on meal-plan/shopping-list headers (v0.28.0)
Match the recipe detail page's action-row convention: Share, Generate
shopping list, Shopping lists link, Print, Export to calendar, and
Send to grocery delivery collapse to icon-only with a tooltip instead
of icon+text, freeing up header space. Week prev/next arrows moved
next to the week date instead of sitting at the end of the button row.

Note: verified via typecheck/lint only — this dev environment has no
authenticated session to visually confirm in-browser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:29:22 +02:00

203 lines
8.5 KiB
TypeScript
Raw 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 type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer, CalendarDays } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
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">
<TooltipProvider>
<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>
<div className="flex items-center gap-1 mt-1">
<Tooltip>
<TooltipTrigger render={
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7")} aria-label={msgs.mealPlan.previousWeek}>
<ChevronLeft className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.previousWeek}</TooltipContent>
</Tooltip>
<p className="text-muted-foreground">{label}</p>
<Tooltip>
<TooltipTrigger render={
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7")} aria-label={msgs.mealPlan.nextWeek}>
<ChevronRight className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.nextWeek}</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} />
<NewShoppingListButton
defaultWeekStart={weekStart}
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
/>
<Tooltip>
<TooltipTrigger render={
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.shoppingLists}>
<ShoppingCart className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.shoppingLists}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger render={
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.common.print}>
<Printer className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.common.print}</TooltipContent>
</Tooltip>
<ExportMarkdownButton
markdown={mealPlanToMarkdown({ label, entries })}
filename={`meal-plan-${weekStart}`}
/>
<Tooltip>
<TooltipTrigger render={
<a href={`/api/v1/meal-plans/${weekStart}/export/ics`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.exportCalendar}>
<CalendarDays className="h-4 w-4" />
</a>
} />
<TooltipContent>{msgs.mealPlan.exportCalendar}</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
<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>
);
}