diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts
index 9d57cca..3cdb3b1 100644
--- a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts
+++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { db, mealPlans, mealPlanEntries, recipes, eq, and, or, ne } from "@epicure/db";
+import { db, mealPlans, mealPlanEntries, recipes, recipeBatchDishes, eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
@@ -10,6 +10,7 @@ const Schema = z.object({
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
recipeId: z.string().optional(),
+ batchDishId: z.string().optional(),
servings: z.number().int().min(1).max(100).default(2),
note: z.string().max(500).optional(),
});
@@ -44,6 +45,14 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!recipe) return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
}
+ if (parsed.data.batchDishId) {
+ if (!parsed.data.recipeId) return NextResponse.json({ error: "batchDishId requires recipeId" }, { status: 400 });
+ const dish = await db.query.recipeBatchDishes.findFirst({
+ where: and(eq(recipeBatchDishes.id, parsed.data.batchDishId), eq(recipeBatchDishes.recipeId, parsed.data.recipeId)),
+ });
+ if (!dish) return NextResponse.json({ error: "Dish not found" }, { status: 404 });
+ }
+
const plan = await getOrCreatePlan(session!.user.id, weekStart);
// Remove existing entry for same day+mealType before inserting
@@ -62,6 +71,7 @@ export async function POST(req: NextRequest, { params }: Params) {
day: parsed.data.day,
mealType: parsed.data.mealType,
recipeId: parsed.data.recipeId,
+ batchDishId: parsed.data.batchDishId,
servings: parsed.data.servings,
note: parsed.data.note,
});
diff --git a/apps/web/app/print/[id]/page.tsx b/apps/web/app/print/[id]/page.tsx
index 79c2d1c..22670a7 100644
--- a/apps/web/app/print/[id]/page.tsx
+++ b/apps/web/app/print/[id]/page.tsx
@@ -21,6 +21,7 @@ export default async function RecipePrintPage({ params }: Params) {
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
+ batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
},
});
@@ -28,6 +29,16 @@ export default async function RecipePrintPage({ params }: Params) {
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
+ const stepGroups: Array<{ label: string; steps: typeof recipe.steps }> = [];
+ if (recipe.isBatchCook) {
+ for (const step of recipe.steps) {
+ const label = step.appliesTo.length === 0 ? m.recipe.batchCookPrep : step.appliesTo.join(" + ");
+ const last = stepGroups[stepGroups.length - 1];
+ if (last && last.label === label) last.steps.push(step);
+ else stepGroups.push({ label, steps: [step] });
+ }
+ }
+
const activeTags = Object.entries(recipe.dietaryTags ?? {})
.filter(([, v]) => v)
.map(([k]) => m.recipe.dietary[k as keyof typeof m.recipe.dietary])
@@ -62,6 +73,11 @@ export default async function RecipePrintPage({ params }: Params) {
ol.steps { padding-left: 20px; margin: 0; }
ol.steps li { padding: 6px 0 6px 4px; border-bottom: 1px dotted #e0e0e0; font-size: 0.95em; }
ol.steps li:last-child { border-bottom: none; }
+ h3.section { font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.06em; color: #777; margin: 18px 0 6px; font-family: system-ui, sans-serif; }
+ .dish { border: 1px solid #ddd; border-radius: 6px; padding: 10px 14px; margin: 10px 0; font-family: system-ui, sans-serif; }
+ .dish-name { font-weight: 600; margin: 0 0 4px; }
+ .dish-storage { font-size: 0.85em; color: #666; margin: 0 0 6px; }
+ .dish-dayof { font-size: 0.9em; margin: 0; }
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
.print-btn {
@@ -117,16 +133,46 @@ export default async function RecipePrintPage({ params }: Params) {
{recipe.steps.length > 0 && (
<>
{m.recipe.instructions}
-
- {recipe.steps.map((step) => (
- -
- {step.instruction}
- {step.timerSeconds && (
- ⏱ {Math.floor(step.timerSeconds / 60)} min
- )}
-
- ))}
-
+ {recipe.isBatchCook ? (
+ stepGroups.map((group, gi) => (
+
+
{group.label}
+
+ {group.steps.map((step) => (
+ - {step.instruction}
+ ))}
+
+
+ ))
+ ) : (
+
+ {recipe.steps.map((step) => (
+ -
+ {step.instruction}
+ {step.timerSeconds && (
+ ⏱ {Math.floor(step.timerSeconds / 60)} min
+ )}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {recipe.isBatchCook && recipe.batchDishes.length > 0 && (
+ <>
+ {m.recipe.batchCookDishesTitle}
+ {recipe.batchDishes.map((dish) => (
+
+
{dish.name}
+
+ {formatMessage(m.recipe.batchCookFridgeDays, { days: dish.fridgeDays })}
+ {dish.freezerFriendly && ` · ${m.recipe.batchCookFreezerFriendly}`}
+ {dish.freezerNote && ` — ${dish.freezerNote}`}
+
+
{m.recipe.batchCookDayOf}: {dish.dayOfInstructions}
+
+ ))}
>
)}
diff --git a/apps/web/app/print/layout.tsx b/apps/web/app/print/layout.tsx
new file mode 100644
index 0000000..191535f
--- /dev/null
+++ b/apps/web/app/print/layout.tsx
@@ -0,0 +1,15 @@
+export default function PrintLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/apps/web/components/cooking-mode/cooking-mode.tsx b/apps/web/components/cooking-mode/cooking-mode.tsx
index e671fa7..c58ca74 100644
--- a/apps/web/components/cooking-mode/cooking-mode.tsx
+++ b/apps/web/components/cooking-mode/cooking-mode.tsx
@@ -9,7 +9,7 @@ import { useLocale } from "@/lib/i18n/provider";
import { formatIngredientQuantity, type UnitPref } from "@/lib/unit-conversion";
import { useWakeLock } from "@/lib/hooks/use-wake-lock";
-type Step = { id: string; instruction: string; timerSeconds: number | null };
+type Step = { id: string; instruction: string; timerSeconds: number | null; appliesTo?: string[] };
type Ingredient = { rawName: string; quantity: string | null; unit: string | null };
type TimerState = {
@@ -287,6 +287,13 @@ export function CookingMode({
{t("stepOf", { current: current + 1, total: steps.length })}
+ {/* Batch-cook dish tag */}
+ {step.appliesTo !== undefined && (
+
+ {step.appliesTo.length === 0 ? t("batchPrepLabel") : step.appliesTo.join(" + ")}
+
+ )}
+
{/* Instruction */}
{step.instruction}
diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx
index 0348f20..cd2f874 100644
--- a/apps/web/components/meal-plan/meal-planner.tsx
+++ b/apps/web/components/meal-plan/meal-planner.tsx
@@ -15,16 +15,18 @@ type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
type Recipe = { id: string; title: string };
+type BatchDish = { id: string; name: string };
type Entry = {
id: string;
day: Day;
mealType: MealType;
servings: number;
recipe: Recipe | null;
+ batchDish: BatchDish | null;
note: string | null;
};
-type UserRecipe = { id: string; title: string };
+type UserRecipe = { id: string; title: string; isBatchCook: boolean; batchDishes: BatchDish[] };
function AddEntryModal({
weekStart,
@@ -48,29 +50,72 @@ function AddEntryModal({
const [search, setSearch] = useState("");
const [servings, setServings] = useState("2");
const [saving, setSaving] = useState(false);
+ const [pickingDishFor, setPickingDishFor] = useState(null);
const t = useTranslations("mealPlan");
const filtered = userRecipes.filter((r) =>
r.title.toLowerCase().includes(search.toLowerCase())
);
- async function add(recipe: UserRecipe) {
+ async function add(recipe: UserRecipe, batchDish?: BatchDish) {
setSaving(true);
try {
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ day, mealType, recipeId: recipe.id, servings: parseInt(servings) || 2 }),
+ body: JSON.stringify({
+ day,
+ mealType,
+ recipeId: recipe.id,
+ batchDishId: batchDish?.id,
+ servings: parseInt(servings) || 2,
+ }),
});
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
- onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, note: null });
+ onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, batchDish: batchDish ?? null, note: null });
onClose();
} finally {
setSaving(false);
}
}
+ function selectRecipe(recipe: UserRecipe) {
+ if (recipe.isBatchCook && recipe.batchDishes.length > 0) {
+ setPickingDishFor(recipe);
+ return;
+ }
+ void add(recipe);
+ }
+
+ if (pickingDishFor) {
+ return (
+
+ );
+ }
+
return (