feat: adapt recipe surfaces for batch-cook sessions
- Recipes page action buttons reordered/restyled to push AI generation and batch cooking ahead of manual recipe creation. - Recipe detail page hides meal/drink pairing (doesn't make sense for a multi-dish batch session). - Print pages force light mode regardless of app theme (dark bg + hardcoded dark text was unreadable) and render sectioned steps + dishes/storage for batch-cook recipes. - Markdown export mirrors the same sectioned structure. - Cooking mode tags each step with which dish(es) it belongs to. - Meal planner: mealPlanEntries.batchDishId lets a slot point at one specific dish within a batch session; picking a batch-cook recipe now prompts for which dish, and the grid shows the dish name. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,14 +58,15 @@ export default async function MealPlanPage({
|
||||
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
|
||||
with: {
|
||||
entries: {
|
||||
with: { recipe: { with: { photos: true } } },
|
||||
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 },
|
||||
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),
|
||||
@@ -88,6 +89,7 @@ export default async function MealPlanPage({
|
||||
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" })}`;
|
||||
|
||||
@@ -41,6 +41,7 @@ export default async function CookPage({ params }: Params) {
|
||||
id: s.id,
|
||||
instruction: s.instruction,
|
||||
timerSeconds: s.timerSeconds,
|
||||
appliesTo: recipe.isBatchCook ? s.appliesTo : undefined,
|
||||
}))}
|
||||
ingredients={recipe.ingredients.map((i) => ({
|
||||
rawName: i.rawName,
|
||||
|
||||
@@ -151,8 +151,12 @@ export default async function RecipePage({ params }: Params) {
|
||||
</Tooltip>
|
||||
)}
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
{!recipe.isBatchCook && (
|
||||
<>
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
</>
|
||||
)}
|
||||
{recipe.visibility === "public" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
@@ -217,6 +221,8 @@ export default async function RecipePage({ params }: Params) {
|
||||
sourceUrl: recipe.sourceUrl,
|
||||
ingredients: recipe.ingredients,
|
||||
steps: recipe.steps,
|
||||
isBatchCook: recipe.isBatchCook,
|
||||
batchDishes: recipe.batchDishes,
|
||||
})}
|
||||
filename={recipe.title}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<h2>{m.recipe.instructions}</h2>
|
||||
<ol className="steps">
|
||||
{recipe.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.instruction}
|
||||
{step.timerSeconds && (
|
||||
<span className="timer">⏱ {Math.floor(step.timerSeconds / 60)} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{recipe.isBatchCook ? (
|
||||
stepGroups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
<h3 className="section">{group.label}</h3>
|
||||
<ol className="steps">
|
||||
{group.steps.map((step) => (
|
||||
<li key={step.id}>{step.instruction}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<ol className="steps">
|
||||
{recipe.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.instruction}
|
||||
{step.timerSeconds && (
|
||||
<span className="timer">⏱ {Math.floor(step.timerSeconds / 60)} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
|
||||
<>
|
||||
<h2>{m.recipe.batchCookDishesTitle}</h2>
|
||||
{recipe.batchDishes.map((dish) => (
|
||||
<div key={dish.id} className="dish">
|
||||
<p className="dish-name">{dish.name}</p>
|
||||
<p className="dish-storage">
|
||||
{formatMessage(m.recipe.batchCookFridgeDays, { days: dish.fridgeDays })}
|
||||
{dish.freezerFriendly && ` · ${m.recipe.batchCookFreezerFriendly}`}
|
||||
{dish.freezerNote && ` — ${dish.freezerNote}`}
|
||||
</p>
|
||||
<p className="dish-dayof"><strong>{m.recipe.batchCookDayOf}:</strong> {dish.dayOfInstructions}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function PrintLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
html, html.dark, html:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
body {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
`}</style>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 })}
|
||||
</div>
|
||||
|
||||
{/* Batch-cook dish tag */}
|
||||
{step.appliesTo !== undefined && (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide rounded-full bg-muted px-3 py-1">
|
||||
{step.appliesTo.length === 0 ? t("batchPrepLabel") : step.appliesTo.join(" + ")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Instruction */}
|
||||
<p className="text-2xl sm:text-3xl leading-relaxed text-center font-medium max-w-2xl">
|
||||
{step.instruction}
|
||||
|
||||
@@ -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<UserRecipe | null>(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 (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pickDishTitle", { recipe: pickingDishFor.title })}</DialogTitle>
|
||||
<DialogDescription>{t("pickDishDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{pickingDishFor.batchDishes.map((dish) => (
|
||||
<button
|
||||
key={dish.id}
|
||||
onClick={() => { void add(pickingDishFor, dish); }}
|
||||
disabled={saving}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
{dish.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setPickingDishFor(null)} disabled={saving}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -99,11 +144,16 @@ function AddEntryModal({
|
||||
filtered.map((recipe) => (
|
||||
<button
|
||||
key={recipe.id}
|
||||
onClick={() => add(recipe)}
|
||||
onClick={() => selectRecipe(recipe)}
|
||||
disabled={saving}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
|
||||
className="w-full flex items-center justify-between gap-2 text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
{recipe.title}
|
||||
<span>{recipe.title}</span>
|
||||
{recipe.isBatchCook && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground border rounded-full px-2 py-0.5">
|
||||
{t("batchCookBadge")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -185,6 +235,7 @@ export function MealPlanner({
|
||||
mealType: e.mealType as MealType,
|
||||
servings: parseInt(aiServings) || 2,
|
||||
recipe: { id: e.recipeId, title: e.recipeTitle },
|
||||
batchDish: null,
|
||||
note: null,
|
||||
};
|
||||
if (idx >= 0) updated[idx] = newEntry;
|
||||
@@ -310,7 +361,7 @@ export function MealPlanner({
|
||||
href={`/recipes/${entry.recipe.id}`}
|
||||
className="block pr-8 hover:underline"
|
||||
>
|
||||
<div className="font-medium line-clamp-2">{entry.recipe.title}</div>
|
||||
<div className="font-medium line-clamp-2">{entry.batchDish?.name ?? entry.recipe.title}</div>
|
||||
<div className="text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{t("servingsAbbrev", { count: entry.servings })}
|
||||
{cookedIds.has(entry.id) && (
|
||||
|
||||
@@ -122,19 +122,19 @@ export function RecipesHeader({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" className="gap-1.5" onClick={() => setAiOpen(true)}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("generate")}
|
||||
</Button>
|
||||
<Link href="/batch-cooking" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
|
||||
<ChefHat className="h-4 w-4" />
|
||||
{t("batchCooking")}
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setUrlOpen(true)}>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("importUrl")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setAiOpen(true)}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("generate")}
|
||||
</Button>
|
||||
<Link href="/recipes/new" className={cn(buttonVariants({ size: "sm" }))}>
|
||||
<Link href="/recipes/new" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1.5")}>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
{t("new")}
|
||||
</Link>
|
||||
|
||||
@@ -7,7 +7,15 @@ type RecipeMarkdownInput = {
|
||||
difficulty: "easy" | "medium" | "hard" | null;
|
||||
sourceUrl: string | null;
|
||||
ingredients: Array<{ rawName: string; quantity: string | null; unit: string | null; note: string | null }>;
|
||||
steps: Array<{ instruction: string; timerSeconds: number | null }>;
|
||||
steps: Array<{ instruction: string; timerSeconds: number | null; appliesTo?: string[] }>;
|
||||
isBatchCook?: boolean;
|
||||
batchDishes?: Array<{
|
||||
name: string;
|
||||
fridgeDays: number;
|
||||
freezerFriendly: boolean;
|
||||
freezerNote: string | null;
|
||||
dayOfInstructions: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatQuantity(quantity: string | null, unit: string | null): string {
|
||||
@@ -39,13 +47,39 @@ export function recipeToMarkdown(recipe: RecipeMarkdownInput): string {
|
||||
|
||||
if (recipe.steps.length > 0) {
|
||||
lines.push("## Instructions", "");
|
||||
recipe.steps.forEach((step, i) => {
|
||||
const timer = step.timerSeconds ? ` (${Math.round(step.timerSeconds / 60)} min)` : "";
|
||||
lines.push(`${i + 1}. ${step.instruction}${timer}`);
|
||||
});
|
||||
if (recipe.isBatchCook) {
|
||||
let lastLabel = "";
|
||||
let n = 0;
|
||||
for (const step of recipe.steps) {
|
||||
const label = !step.appliesTo || step.appliesTo.length === 0 ? "Prep" : step.appliesTo.join(" + ");
|
||||
if (label !== lastLabel) {
|
||||
if (lastLabel) lines.push("");
|
||||
lines.push(`### ${label}`, "");
|
||||
lastLabel = label;
|
||||
n = 0;
|
||||
}
|
||||
n += 1;
|
||||
lines.push(`${n}. ${step.instruction}`);
|
||||
}
|
||||
} else {
|
||||
recipe.steps.forEach((step, i) => {
|
||||
const timer = step.timerSeconds ? ` (${Math.round(step.timerSeconds / 60)} min)` : "";
|
||||
lines.push(`${i + 1}. ${step.instruction}${timer}`);
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (recipe.isBatchCook && recipe.batchDishes && recipe.batchDishes.length > 0) {
|
||||
lines.push("## Dishes & storage", "");
|
||||
for (const dish of recipe.batchDishes) {
|
||||
const storage = [`Fridge: ${dish.fridgeDays}d`];
|
||||
if (dish.freezerFriendly) storage.push("Freezer-friendly");
|
||||
lines.push(`**${dish.name}**`, storage.join(" · ") + (dish.freezerNote ? ` — ${dish.freezerNote}` : ""));
|
||||
lines.push(`On the day: ${dish.dayOfInstructions}`, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (recipe.sourceUrl) {
|
||||
lines.push(`Source: ${recipe.sourceUrl}`);
|
||||
}
|
||||
|
||||
@@ -676,6 +676,10 @@
|
||||
"sharedPlanOf": "{name}'s plan",
|
||||
"weekOf": "Week of {date}",
|
||||
"sharedMealPlanTitle": "{name}'s Meal Plan",
|
||||
"batchCookBadge": "Batch cooking",
|
||||
"pickDishTitle": "Which dish from \"{recipe}\"?",
|
||||
"pickDishDescription": "This is a batch-cooking session — pick the specific dish for this slot.",
|
||||
"back": "Back",
|
||||
"addTo": "Add to {day} – {meal}",
|
||||
"searchRecipes": "Search recipes…",
|
||||
"servings": "Servings",
|
||||
@@ -977,6 +981,7 @@
|
||||
"cookingMode": {
|
||||
"cooking": "Cooking",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"batchPrepLabel": "Prep",
|
||||
"ingredients": "Ingredients",
|
||||
"startTimer": "Start timer",
|
||||
"timerDone": "Done",
|
||||
|
||||
@@ -664,6 +664,10 @@
|
||||
"sharedPlanOf": "Planning de {name}",
|
||||
"weekOf": "Semaine du {date}",
|
||||
"sharedMealPlanTitle": "Planning repas de {name}",
|
||||
"batchCookBadge": "Batch cooking",
|
||||
"pickDishTitle": "Quel plat de \"{recipe}\" ?",
|
||||
"pickDishDescription": "C'est une session de batch cooking — choisissez le plat précis pour ce créneau.",
|
||||
"back": "Retour",
|
||||
"addTo": "Ajouter à {day} – {meal}",
|
||||
"searchRecipes": "Rechercher des recettes…",
|
||||
"servings": "Portions",
|
||||
@@ -965,6 +969,7 @@
|
||||
"cookingMode": {
|
||||
"cooking": "En cuisine",
|
||||
"stepOf": "Étape {current} sur {total}",
|
||||
"batchPrepLabel": "Préparation",
|
||||
"ingredients": "Ingrédients",
|
||||
"startTimer": "Démarrer le minuteur",
|
||||
"timerDone": "Terminé",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "meal_plan_entries" ADD COLUMN "batch_dish_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "meal_plan_entries" ADD CONSTRAINT "meal_plan_entries_batch_dish_id_recipe_batch_dishes_id_fk" FOREIGN KEY ("batch_dish_id") REFERENCES "public"."recipe_batch_dishes"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,13 @@
|
||||
"when": 1783810665920,
|
||||
"tag": "0029_tired_crystal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1783814978348,
|
||||
"tag": "0030_kind_lightspeed",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { users } from "./users";
|
||||
import { recipes } from "./recipes";
|
||||
import { recipes, recipeBatchDishes } from "./recipes";
|
||||
import { ingredients } from "./recipes";
|
||||
import { collectionMemberRoleEnum } from "./social";
|
||||
|
||||
@@ -35,6 +35,7 @@ export const mealPlanEntries = pgTable("meal_plan_entries", {
|
||||
day: weekdayEnum("day").notNull(),
|
||||
mealType: mealTypeEnum("meal_type").notNull(),
|
||||
recipeId: text("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
|
||||
batchDishId: text("batch_dish_id").references(() => recipeBatchDishes.id, { onDelete: "set null" }),
|
||||
servings: integer("servings").notNull().default(2),
|
||||
note: text("note"),
|
||||
}, (t) => [
|
||||
@@ -107,6 +108,7 @@ export const mealPlansRelations = relations(mealPlans, ({ one, many }) => ({
|
||||
export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) => ({
|
||||
mealPlan: one(mealPlans, { fields: [mealPlanEntries.mealPlanId], references: [mealPlans.id] }),
|
||||
recipe: one(recipes, { fields: [mealPlanEntries.recipeId], references: [recipes.id] }),
|
||||
batchDish: one(recipeBatchDishes, { fields: [mealPlanEntries.batchDishId], references: [recipeBatchDishes.id] }),
|
||||
}));
|
||||
|
||||
export const mealPlanMembersRelations = relations(mealPlanMembers, ({ one }) => ({
|
||||
|
||||
Reference in New Issue
Block a user