Files
Epicure/apps/web/app/(app)/meal-plan/page.tsx
T
Arnaud e5d1080fb9 feat: implement remaining TODO.md feature ideas + fix mobile headers
Implements the six previously-unscoped feature ideas plus a mobile
layout fix reported via screenshot:

- Mobile: Recipes/Collections/Pantry/Meal Plan/Shopping Lists headers
  now stack and wrap instead of clipping buttons on narrow viewports.
- Recipe diff/compare view: word/list diff against any past version,
  next to Restore in version history.
- Shared meal plans & shopping lists: new shoppingListMembers/
  mealPlanMembers tables (viewer/editor roles, mirrors
  collectionMembers), share dialogs, membership-checked routes.
- PDF cookbook export: /print/collection/[id] renders a whole
  collection with page breaks, using the existing print-CSS pattern
  instead of adding a PDF rendering dependency.
- Grocery delivery handoff: shopping lists can copy-as-text (works
  today) or send to Instacart once INSTACART_API_KEY is configured
  (stub adapter — real API needs a partner agreement).
- Personalized "For You" feed tab: ranks public recipes by tag/
  dietary overlap with the user's favorited/highly-rated history.
- PWA: added manifest.json + icons on top of the existing service
  worker so the app is installable; cook-mode pages were already
  cached for offline use.

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

133 lines
4.9 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 } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, mealPlanMembers, recipes, 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 { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Meal Plan" };
function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date();
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
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 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] = 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 } } },
},
},
}),
db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id),
orderBy: desc(recipes.updatedAt),
columns: { id: true, title: true },
}),
db.query.mealPlanMembers.findMany({
where: eq(mealPlanMembers.userId, session.user.id),
with: { mealPlan: { with: { user: true } } },
}),
]);
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,
}));
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">Meal Plan</h1>
<p className="text-muted-foreground mt-1">{label}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} />
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ShoppingCart className="h-4 w-4" />
Shopping lists
</Link>
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
Print
</Link>
<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} />
{sharedMemberships.length > 0 && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">Shared with you</h2>
<div className="space-y-2 max-w-lg">
{sharedMemberships.map((m) => (
<Link
key={m.id}
href={`/meal-plan/shared/${m.mealPlan.id}`}
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
>
<div>
<p className="font-medium">{`${m.mealPlan.user?.name ?? "Unknown"}'s plan`}</p>
<p className="text-sm text-muted-foreground">Week of {m.mealPlan.weekStart} · {m.role}</p>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}