171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
|
|
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<MealPlanState, "daysCount" | "mealsPerDay" | "slots" | "householdSize">;
|
|||
|
|
|
|||
|
|
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<string, string> = { "⅓": " 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;
|
|||
|
|
}
|