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>
This commit is contained in:
parent
b02c7a024b
commit
df5dd3a70e
56 changed files with 4749 additions and 788 deletions
170
app/lib/exportPlan.ts
Normal file
170
app/lib/exportPlan.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue