planmymeal/app/lib/exportPlan.ts
Cayden Schwechten df5dd3a70e Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00

170 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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