e5d1080fb9
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>
133 lines
4.9 KiB
TypeScript
133 lines
4.9 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, 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>
|
||
);
|
||
}
|