import type { jsPDF as JsPDF } from "jspdf"; import type { MealPlanState } from "@/app/types"; import { ingredientLines } from "./display"; import { activeSlots, mealLabel } from "./plan"; import { RECIPES_BY_ID } from "./recipes"; // --------------------------------------------------------------------------- // Downloadable weekly plan as a PDF, organized by day, with the full recipe // (scaled ingredients + steps) under every planned meal. // // Two layers: `buildPlanModel` (pure, tested) decides the content; // `renderPlanPdf` lays it out with jsPDF. The planner loads jsPDF on click. // --------------------------------------------------------------------------- const FULL_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; interface PlannedMeal { label: string; title: string; meta: string; description: string; ingredients: string[]; steps: string[]; tip?: string; } interface PlanModel { intro: string; days: { name: string; meals: (PlannedMeal | { label: string; empty: true })[] }[]; } type PlanInput = Pick; export function buildPlanModel(state: PlanInput): PlanModel { const servings = state.householdSize; const slots = activeSlots(state); return { intro: `${state.daysCount} day${state.daysCount === 1 ? "" : "s"} · ingredients scaled for ${servings} ${ servings === 1 ? "person" : "people" }. Step wording follows each recipe's original batch size.`, days: FULL_DAY_NAMES.slice(0, state.daysCount).map((name, dayIndex) => ({ name, meals: slots .filter((s) => s.dayIndex === dayIndex) .map((s) => { const label = mealLabel(s.mealNumber); const recipe = s.recipeId ? RECIPES_BY_ID.get(s.recipeId) : undefined; if (!recipe) return { label, empty: true as const }; return { label, title: recipe.title, meta: `${recipe.prepTime} min · serves ${servings}`, description: recipe.description, ingredients: ingredientLines(recipe, servings).map( (l) => `${l.quantity} ${l.unit} ${l.name}${l.detail ? ` (${l.detail})` : ""}`, ), steps: recipe.steps, tip: recipe.tip, }; }), })), }; } // jsPDF's built-in Helvetica only covers WinAnsi (Latin-1 + a few symbols). const SPELLED: Record = { "⅓": " 1/3", "⅔": " 2/3", "⅛": " 1/8", "ı": "i" }; const WINANSI_EXTRA = new Set("€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ"); /** Make text printable in the base PDF font: spell out ⅓-style fractions, decompose or drop anything else. */ export function pdfSafe(text: string): string { let out = ""; for (const ch of text) { const code = ch.codePointAt(0)!; if (code <= 0x7e || (code >= 0xa0 && code <= 0xff) || WINANSI_EXTRA.has(ch)) out += ch; else if (SPELLED[ch]) out += SPELLED[ch]; else out += ch.normalize("NFKD").replace(/[^\x20-\x7e]/g, ""); } // "⅓ cup" → " 1/3 cup": drop the space the spelling adds when nothing precedes it. return out.replace(/(^|\s) (\d\/\d)/g, "$1$2"); } const ACCENT: [number, number, number] = [206, 87, 68]; // matches the app's salmon accent-500 const INK: [number, number, number] = [30, 37, 48]; const MUTED: [number, number, number] = [74, 85, 101]; /** Lay the plan out on US Letter pages; one day per page, recipes never split mid-line. */ export function renderPlanPdf(model: PlanModel, JsPdf: typeof JsPDF): JsPDF { const doc = new JsPdf({ unit: "pt", format: "letter" }); const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const margin = 54; const width = pageW - margin * 2; let y = margin; const ensure = (height: number) => { if (y + height > pageH - margin) { doc.addPage(); y = margin; } }; const write = (text: string, opts: { size: number; bold?: boolean; color?: [number, number, number]; indent?: number; gap?: number }) => { const indent = opts.indent ?? 0; const lineH = opts.size * 1.4; doc.setFont("helvetica", opts.bold ? "bold" : "normal").setFontSize(opts.size).setTextColor(...(opts.color ?? INK)); for (const line of doc.splitTextToSize(pdfSafe(text), width - indent) as string[]) { ensure(lineH); doc.text(line, margin + indent, y + opts.size); y += lineH; } y += opts.gap ?? 0; }; /** Numbered/bulleted item with a hanging indent so wrapped lines align under the text. */ const item = (marker: string, text: string) => { const size = 10.5; const lineH = size * 1.4; doc.setFont("helvetica", "normal").setFontSize(size).setTextColor(...INK); const lines = doc.splitTextToSize(pdfSafe(text), width - 20) as string[]; lines.forEach((line, i) => { ensure(lineH); if (i === 0) doc.text(marker, margin + 4, y + size); doc.text(line, margin + 20, y + size); y += lineH; }); y += 2; }; write("My meal plan", { size: 24, bold: true, gap: 4 }); write(model.intro, { size: 10.5, color: MUTED, gap: 16 }); model.days.forEach((day, dayIndex) => { if (dayIndex > 0) { doc.addPage(); y = margin; } write(day.name, { size: 18, bold: true, gap: 2 }); doc.setDrawColor(...ACCENT).setLineWidth(1.5).line(margin, y, margin + width, y); y += 14; for (const meal of day.meals) { ensure(80); // keep a meal's heading with the start of its recipe write(meal.label.toUpperCase(), { size: 9, bold: true, color: ACCENT, gap: 2 }); if ("empty" in meal) { write("Nothing planned", { size: 10.5, color: MUTED, gap: 16 }); continue; } write(meal.title, { size: 14, bold: true }); write(meal.meta, { size: 9.5, color: MUTED, gap: 4 }); write(meal.description, { size: 10.5, gap: 8 }); write("Ingredients", { size: 11, bold: true, gap: 2 }); meal.ingredients.forEach((line) => item("•", line)); y += 6; write("Steps", { size: 11, bold: true, gap: 2 }); meal.steps.forEach((step, i) => item(`${i + 1}.`, step)); if (meal.tip) { y += 4; write(`Tip: ${meal.tip}`, { size: 10, color: MUTED }); } y += 18; } }); const pages = doc.getNumberOfPages(); for (let page = 1; page <= pages; page++) { doc.setPage(page).setFont("helvetica", "normal").setFontSize(8.5).setTextColor(...MUTED); doc.text(`Plan my Meals · page ${page} of ${pages}`, pageW / 2, pageH - 28, { align: "center" }); } return doc; }