Initial commit

This commit is contained in:
2026-06-04 06:24:56 +02:00
commit 282f4864c4
111 changed files with 8083 additions and 0 deletions

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