Initial commit
This commit is contained in:
179
meal-plan-frontend/src/utils/pdfExport.ts
Normal file
179
meal-plan-frontend/src/utils/pdfExport.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import jsPDF from "jspdf";
|
||||
import html2canvas from "html2canvas";
|
||||
import type { RecipeDetail } from "@/types/recipe.types";
|
||||
|
||||
/**
|
||||
* A single aggregated shopping-list row.
|
||||
* - `unit === null` means the quantity is expressed in grams (`totalGrams`).
|
||||
* - quantified non-gram units (pcs, tbsp, ...) use `totalAmount` + `unit`.
|
||||
* - non-quantified items ("to taste", "for serving", "optional") have `quantified === false`.
|
||||
*/
|
||||
export interface ShoppingItem {
|
||||
name: string;
|
||||
totalGrams: number | null;
|
||||
totalAmount: number | null;
|
||||
unit: string | null;
|
||||
quantified: boolean;
|
||||
sources: string[];
|
||||
/** Per-source contributing amounts, used to render the parenthetical breakdown. */
|
||||
contributions: Array<{ recipe: string; amount: number | null }>;
|
||||
}
|
||||
|
||||
// Unit labels that represent "no concrete quantity" — listed once, never summed.
|
||||
const NON_QUANTIFIED_UNITS = new Set([
|
||||
"to taste",
|
||||
"for serving",
|
||||
"optional",
|
||||
"do smaku",
|
||||
"do podania",
|
||||
"opcjonalnie",
|
||||
"garnish",
|
||||
]);
|
||||
|
||||
function isNonQuantifiedUnit(unit: string | null): boolean {
|
||||
if (!unit) return false;
|
||||
return NON_QUANTIFIED_UNITS.has(unit.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates ingredients across the selected recipes.
|
||||
* Grouping key normalizes name (trim + lowercase); display keeps original capitalization.
|
||||
*/
|
||||
export function buildShoppingList(recipes: RecipeDetail[]): ShoppingItem[] {
|
||||
const buckets = new Map<string, ShoppingItem>();
|
||||
|
||||
for (const recipe of recipes) {
|
||||
for (const ing of recipe.ingredients) {
|
||||
const displayName = ing.name.trim();
|
||||
const normName = displayName.toLowerCase();
|
||||
const unitRaw = ing.unit?.trim() || null;
|
||||
const nonQuantified = isNonQuantifiedUnit(unitRaw) ||
|
||||
(unitRaw === null && ing.amountGrams === null);
|
||||
|
||||
let key: string;
|
||||
if (nonQuantified) {
|
||||
key = `nq:${normName}`;
|
||||
} else if (unitRaw === null) {
|
||||
key = `g:${normName}`; // grams
|
||||
} else {
|
||||
key = `u:${normName}:${unitRaw.toLowerCase()}`;
|
||||
}
|
||||
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
name: displayName,
|
||||
totalGrams: null,
|
||||
totalAmount: null,
|
||||
unit: unitRaw,
|
||||
quantified: !nonQuantified,
|
||||
sources: [],
|
||||
contributions: [],
|
||||
};
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
if (!bucket.sources.includes(recipe.name)) {
|
||||
bucket.sources.push(recipe.name);
|
||||
}
|
||||
|
||||
if (nonQuantified) {
|
||||
continue; // no summing, no quantity
|
||||
}
|
||||
|
||||
const amount = ing.amountGrams ?? null;
|
||||
bucket.contributions.push({ recipe: recipe.name, amount });
|
||||
|
||||
if (unitRaw === null) {
|
||||
bucket.totalGrams = (bucket.totalGrams ?? 0) + (amount ?? 0);
|
||||
} else {
|
||||
bucket.totalAmount = (bucket.totalAmount ?? 0) + (amount ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
/** Right-hand main quantity, e.g. "540 g", "7 pcs", "to taste". */
|
||||
export function formatQuantity(item: ShoppingItem): string {
|
||||
if (!item.quantified) {
|
||||
return item.unit ? item.unit : "to taste";
|
||||
}
|
||||
if (item.unit === null) {
|
||||
return `${formatNumber(item.totalGrams ?? 0)} g`;
|
||||
}
|
||||
const amount = item.totalAmount;
|
||||
return amount === null ? item.unit : `${formatNumber(amount)} ${item.unit}`;
|
||||
}
|
||||
|
||||
/** Parenthetical breakdown shown when the ingredient comes from more than one recipe. */
|
||||
export function formatBreakdown(item: ShoppingItem): string | null {
|
||||
if (!item.quantified || item.sources.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amounts = item.contributions.map((c) => c.amount).filter((a): a is number => a !== null);
|
||||
if (amounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unitLabel = item.unit === null ? "g" : item.unit;
|
||||
const allEqual = amounts.every((a) => a === amounts[0]);
|
||||
|
||||
if (allEqual && amounts.length > 1) {
|
||||
return `${amounts.length} × ${formatNumber(amounts[0])} ${unitLabel}`;
|
||||
}
|
||||
|
||||
return amounts.map((a) => `${formatNumber(a)} ${unitLabel}`).join(" + ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every `.pdf-page` element inside #pdf-render-area into an A4 portrait PDF,
|
||||
* one source element per page, then triggers a download.
|
||||
*/
|
||||
export async function generatePdf(fileName = "meal-plan.pdf"): Promise<void> {
|
||||
const container = document.getElementById("pdf-render-area");
|
||||
if (!container) {
|
||||
throw new Error("PDF render area not found.");
|
||||
}
|
||||
|
||||
const pages = Array.from(container.querySelectorAll<HTMLElement>(".pdf-page"));
|
||||
if (pages.length === 0) {
|
||||
throw new Error("Nothing to export.");
|
||||
}
|
||||
|
||||
const pdf = new jsPDF({ unit: "pt", format: "a4", orientation: "portrait" });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
for (let i = 0; i < pages.length; i += 1) {
|
||||
const canvas = await html2canvas(pages[i], {
|
||||
scale: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.92);
|
||||
const imgWidth = pageWidth;
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
|
||||
if (i > 0) {
|
||||
pdf.addPage();
|
||||
}
|
||||
|
||||
// If a single rendered page is taller than A4, scale it down to fit one page.
|
||||
const finalHeight = Math.min(imgHeight, pageHeight);
|
||||
const finalWidth = finalHeight === imgHeight ? imgWidth : (canvas.width * finalHeight) / canvas.height;
|
||||
pdf.addImage(imgData, "JPEG", (pageWidth - finalWidth) / 2, 0, finalWidth, finalHeight);
|
||||
}
|
||||
|
||||
pdf.save(fileName);
|
||||
}
|
||||
Reference in New Issue
Block a user