planmymeal/app/lib/exportPlan.ts

171 lines
6.5 KiB
TypeScript
Raw Normal View History

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;
}