Files
homepage/src/lib/components/recipes/IngredientsPage.svelte
Alexander Bocken 7e1181461e recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip
Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.
2026-04-01 13:00:55 +02:00

641 lines
20 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { onNavigate } from "$app/navigation";
import { browser } from '$app/environment';
import { page } from '$app/stores';
import HefeSwapper from './HefeSwapper.svelte';
import NutritionSummary from './NutritionSummary.svelte';
let { data } = $props();
// Helper function to multiply numbers in ingredient amounts
/** @param {string} amount @param {number} multiplier */
function multiplyIngredientAmount(amount, multiplier) {
if (!amount || multiplier === 1) return amount;
return amount.replace(/(\d+(?:[\.,]\d+)?)/g, (/** @type {string} */ match) => {
const number = match.includes(',') ? match.replace(/\./g, '').replace(',', '.') : match;
const multiplied = (parseFloat(number) * multiplier).toString();
const rounded = parseFloat(multiplied).toFixed(3);
const trimmed = parseFloat(rounded).toString();
return match.includes(',') ? trimmed.replace('.', ',') : trimmed;
});
}
// Recursively flatten nested ingredient references
/** @param {any[]} items @param {string} lang @param {Set<string>} [visited] @param {number} [baseMultiplier] */
function flattenIngredientReferences(items, lang, visited = new Set(), baseMultiplier = 1) {
const result = [];
for (const item of items) {
if (item.type === 'reference' && item.resolvedRecipe) {
// Prevent circular references
const recipeId = item.resolvedRecipe._id?.toString() || item.resolvedRecipe.short_name;
if (visited.has(recipeId)) {
console.warn('Circular reference detected:', recipeId);
continue;
}
const newVisited = new Set(visited);
newVisited.add(recipeId);
// Get translated or original ingredients
const ingredientsToUse = (lang === 'en' &&
item.resolvedRecipe.translations?.en?.ingredients)
? item.resolvedRecipe.translations.en.ingredients
: item.resolvedRecipe.ingredients || [];
// Calculate combined multiplier for this reference
const itemBaseMultiplier = item.baseMultiplier || 1;
const combinedMultiplier = baseMultiplier * itemBaseMultiplier;
// Recursively flatten nested references with the combined multiplier
const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited, combinedMultiplier);
// Combine all items into one list
const combinedList = [];
// Add items before (not affected by baseMultiplier)
if (item.itemsBefore && item.itemsBefore.length > 0) {
combinedList.push(...item.itemsBefore);
}
// Add base recipe ingredients (now recursively flattened with multiplier applied)
if (item.includeIngredients) {
flattenedNested.forEach(section => {
if (section.list) {
combinedList.push(...section.list);
}
});
}
// Add items after (not affected by baseMultiplier)
if (item.itemsAfter && item.itemsAfter.length > 0) {
combinedList.push(...item.itemsAfter);
}
// Push as one section with optional label
if (combinedList.length > 0) {
const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name)
? item.resolvedRecipe.translations.en.name
: item.resolvedRecipe.name;
const baseRecipeShortName = (lang === 'en' && item.resolvedRecipe.translations?.en?.short_name)
? item.resolvedRecipe.translations.en.short_name
: item.resolvedRecipe.short_name;
result.push({
type: 'section',
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
list: combinedList,
isReference: item.showLabel,
short_name: baseRecipeShortName,
baseMultiplier: itemBaseMultiplier
});
}
} else if (item.type === 'section' || !item.type) {
// Regular section - pass through with multiplier applied to amounts
if (baseMultiplier !== 1 && item.list) {
const adjustedList = item.list.map((/** @type {any} */ ingredient) => ({
...ingredient,
amount: multiplyIngredientAmount(ingredient.amount, baseMultiplier)
}));
result.push({
...item,
list: adjustedList
});
} else {
result.push(item);
}
}
}
return result;
}
// Flatten ingredient references for display
const flattenedIngredients = $derived.by(() => {
if (!data.ingredients) return [];
const lang = data.lang || 'de';
return flattenIngredientReferences(data.ingredients, lang);
});
let multiplier = $state(data.multiplier || 1);
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
portions: isEnglish ? 'Portions:' : 'Portionen:',
adjustAmount: isEnglish ? 'Adjust Amount:' : 'Menge anpassen:',
ingredients: isEnglish ? 'Ingredients' : 'Zutaten',
cakeForm: isEnglish ? 'Cake form:' : 'Backform:',
round: isEnglish ? 'Round' : 'Rund',
rectangular: isEnglish ? 'Rectangular' : 'Rechteckig',
diameter: isEnglish ? 'Diameter' : 'Durchmesser',
width: isEnglish ? 'Width' : 'Breite',
length: isEnglish ? 'Length' : 'Länge',
factor: isEnglish ? 'Factor' : 'Faktor',
});
// Cake form scaling
const hasDefaultForm = $derived(!!data.defaultForm?.shape);
let userFormShape = $state(data.defaultForm?.shape || 'round');
let userFormDiameter = $state(data.defaultForm?.diameter || 26);
let userFormWidth = $state(data.defaultForm?.width || 20);
let userFormLength = $state(data.defaultForm?.length || 30);
let userFormInnerDiameter = $state(data.defaultForm?.innerDiameter || 8);
/** @param {string} shape @param {number} diameter @param {number} width @param {number} length @param {number} innerDiameter */
function calcArea(shape, diameter, width, length, innerDiameter) {
if (shape === 'round') return Math.PI * (diameter / 2) ** 2;
if (shape === 'gugelhupf') return Math.PI * ((diameter / 2) ** 2 - (innerDiameter / 2) ** 2);
return width * length;
}
const defaultFormArea = $derived(
hasDefaultForm
? calcArea(data.defaultForm.shape, data.defaultForm.diameter, data.defaultForm.width, data.defaultForm.length, data.defaultForm.innerDiameter)
: 1
);
const userFormArea = $derived(
calcArea(userFormShape, userFormDiameter, userFormWidth, userFormLength, userFormInnerDiameter)
);
const formMultiplier = $derived(
hasDefaultForm && defaultFormArea > 0 ? userFormArea / defaultFormArea : 1
);
// Track whether multiplier is driven by form or manual buttons
let formDriven = $state(false);
function applyFormMultiplier() {
formDriven = true;
}
// Reactively update multiplier when form dimensions change and form is driving
$effect(() => {
if (formDriven) {
multiplier = formMultiplier;
updateUrl(multiplier);
}
});
/** @param {number} value */
function updateUrl(value) {
if (browser) {
const url = new URL(window.location.href);
if (value === 1) {
url.searchParams.delete('multiplier');
} else {
url.searchParams.set('multiplier', String(value));
}
window.history.replaceState({}, '', url);
}
}
// Multiplier button options
const multiplierOptions = [
{ value: 0.5, label: '<sup>1</sup>/<sub>2</sub>x' },
{ value: 1, label: '1x' },
{ value: 1.5, label: '<sup>3</sup>/<sub>2</sub>x' },
{ value: 2, label: '2x' },
{ value: 3, label: '3x' }
];
// Calculate yeast IDs for each yeast ingredient
const yeastIds = $derived.by(() => {
/** @type {Record<string, number>} */
const ids = {};
let yeastCounter = 0;
if (data.ingredients) {
for (let listIndex = 0; listIndex < data.ingredients.length; listIndex++) {
const list = data.ingredients[listIndex];
if (list.list) {
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
const ingredient = list.list[ingredientIndex];
const nameLower = ingredient.name.toLowerCase();
if (nameLower === "frischhefe" || nameLower === "trockenhefe" ||
nameLower === "fresh yeast" || nameLower === "dry yeast") {
ids[`${listIndex}-${ingredientIndex}`] = yeastCounter++;
}
}
}
}
}
return ids;
});
// Get all current URL parameters to preserve state in multiplier forms
const currentParams = $derived(browser ? new URLSearchParams(window.location.search) : $page.url.searchParams);
// Progressive enhancement - use JS if available
onMount(() => {
if (browser) {
const urlParams = new URLSearchParams(window.location.search);
multiplier = parseFloat(urlParams.get('multiplier') || '1') || 1;
}
})
onNavigate(() => {
if (browser) {
const urlParams = new URLSearchParams(window.location.search);
multiplier = parseFloat(urlParams.get('multiplier') || '1') || 1;
}
})
/** @param {Event} event @param {number} value */
function handleMultiplierClick(event, value) {
if (browser) {
event.preventDefault();
multiplier = value;
formDriven = false;
updateUrl(value);
}
// If no JS, form will submit normally
}
/** @param {Event} event */
function handleCustomInput(event) {
if (browser) {
const value = parseFloat(/** @type {HTMLInputElement} */ (event.target).value);
if (!isNaN(value) && value > 0) {
multiplier = value;
formDriven = false;
updateUrl(value);
}
}
}
/** @param {Event} event */
function handleCustomSubmit(event) {
if (browser) {
event.preventDefault();
// Value already updated by handleCustomInput
}
// If no JS, form will submit normally
}
/** @param {string} inputString */
function convertFloatsToFractions(inputString) {
// Split the input string into individual words
const words = inputString.split(' ');
// Define a helper function to check if a number is close to an integer
const isCloseToInt = (/** @type {number} */ num) => Math.abs(num - Math.round(num)) < 0.001;
// Function to convert a float to a fraction
const floatToFraction = (/** @type {number} */ number) => {
let bestNumerator = 0;
let bestDenominator = 1;
let minDifference = Math.abs(number);
for (let denominator = 1; denominator <= 10; denominator++) {
const numerator = Math.round(number * denominator);
const difference = Math.abs(number - numerator / denominator);
if (difference < minDifference) {
bestNumerator = numerator;
bestDenominator = denominator;
minDifference = difference;
}
}
if (bestDenominator == 1) return bestNumerator;
else {
let full_amount = Math.floor(bestNumerator / bestDenominator);
if (full_amount > 0)
return `${full_amount}<sup>${bestNumerator - full_amount * bestDenominator}</sup>/<sub>${bestDenominator}</sub>`;
return `<sup>${bestNumerator}</sup>/<sub>${bestDenominator}</sub>`;
}
};
// Iterate through the words and convert floats to fractions
const result = words.map((/** @type {string} */ word) => {
// Check if the word contains a range (e.g., "300-400")
if (word.includes('-')) {
const rangeNumbers = word.split('-');
const rangeFractions = rangeNumbers.map((/** @type {string} */ num) => {
const number = parseFloat(num);
return !isNaN(number) ? floatToFraction(number) : num;
});
return rangeFractions.join('-');
} else {
const number = parseFloat(word);
return !isNaN(number) ? floatToFraction(number) : word;
}
});
// Join the words back into a string
return result.join(' ');
}
/** @param {string} inputString @param {number} constant */
function multiplyNumbersInString(inputString, constant) {
return inputString.replace(/(\d+(?:[\.,]\d+)?)/g, (/** @type {string} */ match) => {
const number = match.includes(',') ? match.replace(/\./g, '').replace(',', '.') : match;
const multiplied = (parseFloat(number) * constant).toString();
const rounded = parseFloat(multiplied).toFixed(3);
const trimmed = parseFloat(rounded).toString();
return match.includes(',') ? trimmed.replace('.', ',') : trimmed;
});
}
// "1-2 Kuchen (Durchmesser: 26cm", constant=2 -> "2-4 Kuchen (Durchmesser: 26cm)"
/** @param {string} inputString @param {number} constant */
function multiplyFirstAndSecondNumbers(inputString, constant) {
const regex = /(\d+(?:[\.,]\d+)?)(\s*-\s*\d+(?:[\.,]\d+)?)?/;
return inputString.replace(regex, (/** @type {string} */ match, /** @type {string} */ firstNumber, /** @type {string} */ secondNumber) => {
const numbersToMultiply = [firstNumber];
if (secondNumber) {
numbersToMultiply.push(secondNumber.replace(/-\s*/, ''));
}
const multipliedNumbers = numbersToMultiply.map(number => {
const multiplied = (parseFloat(number) * constant).toString();
const rounded = parseFloat(multiplied).toString();
const result = number.includes(',') ? rounded.replace('.', ',') : rounded;
return result;
});
return multipliedNumbers.join('-')
});
}
/** @param {string} string @param {number} multiplier */
function adjust_amount(string, multiplier){
let temp = multiplyNumbersInString(string, multiplier)
temp = convertFloatsToFractions(temp)
return temp
}
// Collect section names for nutrition dedup (skip ingredients matching another section's name)
const nutritionSectionNames = $derived.by(() => {
if (!data.ingredients) return new Set();
const names = new Set();
for (const section of data.ingredients) {
if (section.name) {
const stripped = section.name.replace(/<[^>]*>/g, '').toLowerCase().trim();
if (stripped) names.add(stripped);
}
}
return names;
});
// Build flat ingredient list with section/ingredient indices for nutrition calculator
const nutritionFlatIngredients = $derived.by(() => {
if (!data.ingredients) return [];
/** @type {{ name: string; unit: string; amount: string; sectionIndex: number; ingredientIndex: number; sectionName: string }[]} */
const flat = [];
for (let si = 0; si < data.ingredients.length; si++) {
const section = data.ingredients[si];
if (section.type === 'reference') continue;
if (!section.list) continue;
const sectionName = (section.name || '').replace(/<[^>]*>/g, '').toLowerCase().trim();
for (let ii = 0; ii < section.list.length; ii++) {
const item = section.list[ii];
flat.push({
name: item.name,
unit: item.unit || '',
amount: item.amount || '',
sectionIndex: si,
ingredientIndex: ii,
sectionName,
});
}
}
return flat;
});
// No need for complex yeast toggle handling - everything is calculated server-side now
</script>
<style>
.ingredients{
flex-basis: 0;
flex-grow: 1;
padding-block: 1rem;
padding-inline: 2rem;
}
.ingredients_grid{
display: grid;
font-size: 1.1rem;
grid-template-columns: 1fr 3fr;
grid-template-rows: auto;
grid-auto-flow: row;
row-gap: 0.5em;
column-gap: 0.5em;
}
.multipliers{
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
/* Size overrides for multiplier buttons */
.multipliers button{
min-width: 2em;
font-size: 1.1rem;
border-radius: var(--radius-sm);
}
/* Hover scale override - larger than default */
.multipliers :is(button, form):is(:hover, :focus-within){
scale: 1.2;
background-color: var(--color-primary);
color: var(--color-text-on-primary);
}
.selected{
background-color: var(--color-primary) !important;
color: var(--color-text-on-primary) !important;
font-weight: bold;
scale: 1.2 !important;
}
.custom-multiplier {
display: flex;
align-items: center;
min-width: 2em;
font-size: 1.1rem;
border-radius: var(--radius-sm);
}
.custom-input {
width: 3em;
padding: 0;
margin: 0;
border: none;
background: transparent;
text-align: center;
color: inherit;
font-size: inherit;
outline: none;
box-shadow: none;
}
/* Remove number input arrows */
.custom-input::-webkit-outer-spin-button,
.custom-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.custom-button {
padding: 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
font-size: inherit;
cursor: pointer;
box-shadow: none;
}
.cake-form {
margin-block: 1rem;
}
.cake-form-shape {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-bottom: 0.5rem;
}
.cake-form-shape label {
cursor: pointer;
padding: 0.25em 0.6em;
border-radius: var(--radius-sm);
transition: var(--transition-fast);
}
.cake-form-shape input[type="radio"] {
display: none;
}
.cake-form-selected {
background-color: var(--color-primary);
color: var(--color-text-on-primary);
font-weight: bold;
}
.cake-form-inputs {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.cake-form-num {
width: 3.5em;
padding: 0.2em 0.4em;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
font-size: inherit;
background: transparent;
color: inherit;
}
.cake-form-factor {
text-align: center;
margin-top: 0.4rem;
font-weight: bold;
color: var(--color-primary);
}
</style>
{#if data.ingredients}
<div class=ingredients>
{#if data.portions}
<h3>{labels.portions}</h3>
{@html convertFloatsToFractions(multiplyFirstAndSecondNumbers(data.portions, multiplier))}
{/if}
<h3>{labels.adjustAmount}</h3>
<form method="get" class="multipliers">
{#each Array.from(currentParams.entries()) as [key, value]}
{#if key !== 'multiplier'}
<input type="hidden" name={key} {value} />
{/if}
{/each}
{#each multiplierOptions as opt}
<button type="submit" name="multiplier" value={opt.value} class="g-pill g-btn-light g-interactive" class:selected={multiplier === opt.value} onclick={(e) => handleMultiplierClick(e, opt.value)}>{@html opt.label}</button>
{/each}
<span class="custom-multiplier g-pill g-btn-light g-interactive">
<input
type="text"
name="multiplier"
pattern="[0-9]+(\.[0-9]*)?"
title="Enter a positive number (e.g., 2.5, 0.75, 3.14)"
placeholder="…"
class="custom-input"
value={!multiplierOptions.some(o => o.value === multiplier) ? multiplier : ''}
oninput={handleCustomInput}
/>
<button type="submit" class="custom-button">x</button>
</span>
</form>
{#if hasDefaultForm}
<div class="cake-form">
<h3>{labels.cakeForm}</h3>
<div class="cake-form-shape">
<label class:cake-form-selected={userFormShape === 'round'}>
<input type="radio" name="userFormShape" value="round" bind:group={userFormShape} onchange={applyFormMultiplier} />
{labels.round}
</label>
<label class:cake-form-selected={userFormShape === 'rectangular'}>
<input type="radio" name="userFormShape" value="rectangular" bind:group={userFormShape} onchange={applyFormMultiplier} />
{labels.rectangular}
</label>
{#if data.defaultForm?.shape === 'gugelhupf'}
<label class:cake-form-selected={userFormShape === 'gugelhupf'}>
<input type="radio" name="userFormShape" value="gugelhupf" bind:group={userFormShape} onchange={applyFormMultiplier} />
Gugelhupf
</label>
{/if}
</div>
<div class="cake-form-inputs">
{#if userFormShape === 'round'}
<label>{labels.diameter}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormDiameter} oninput={applyFormMultiplier} /> cm</label>
{:else if userFormShape === 'rectangular'}
<label>{labels.width}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormWidth} oninput={applyFormMultiplier} /> cm</label>
<label>{labels.length}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormLength} oninput={applyFormMultiplier} /> cm</label>
{:else if userFormShape === 'gugelhupf'}
<label>{isEnglish ? 'Outer Ø' : 'Aussen-Ø'}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormDiameter} oninput={applyFormMultiplier} /> cm</label>
<label>{isEnglish ? 'Inner Ø' : 'Innen-Ø'}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormInnerDiameter} oninput={applyFormMultiplier} /> cm</label>
{/if}
</div>
{#if formDriven}
<div class="cake-form-factor">{labels.factor}: {formMultiplier.toFixed(2)}x</div>
{/if}
</div>
{/if}
<h2>{labels.ingredients}</h2>
{#each flattenedIngredients as list, listIndex}
{#if list.name}
{#if list.isReference}
<h3><a href="{list.short_name}?multiplier={multiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
{:else}
<h3>{@html list.name}</h3>
{/if}
{/if}
{#if list.list}
<div class=ingredients_grid>
{#each list.list as item, ingredientIndex}
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
<div class=name>
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? multiplier : multiplier * parseFloat(item.amount))}
{#if item.name.toLowerCase() === "frischhefe" || item.name.toLowerCase() === "trockenhefe" || item.name.toLowerCase() === "fresh yeast" || item.name.toLowerCase() === "dry yeast"}
{@const yeastId = yeastIds[`${listIndex}-${ingredientIndex}`] ?? 0}
<HefeSwapper {item} {multiplier} {yeastId} lang={data.lang} />
{/if}
</div>
{/each}
</div>
{/if}
{/each}
<NutritionSummary
flatIngredients={nutritionFlatIngredients}
nutritionMappings={data.nutritionMappings}
sectionNames={nutritionSectionNames}
referencedNutrition={data.referencedNutrition || []}
{multiplier}
portions={data.portions}
isEnglish={isEnglish}
/>
</div>
{/if}