feat: expand recipe editing, ingredient import, and costing

This commit is contained in:
2026-08-14 13:38:46 -05:00
parent 20e23b100f
commit deb2c15ab4
15 changed files with 853 additions and 80 deletions
+1
View File
@@ -31,6 +31,7 @@ Thumbs.db
/ui-reference*.html
/ui-reference*_files/
/ui-reference*.png
/screenshots/
# Local application databases and SQLite sidecars
/var/*.sqlite
+6
View File
@@ -64,6 +64,12 @@ Run the editor:
npm run dev:app
```
Ingredient bulk entry uses the local Ollama service through
`http://10.0.10.211:11434/api/chat` and the purpose-built
`qwen3:4b-instruct` parsing prompt. Override these defaults with
`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`. The parser
endpoint is disabled whenever `FORMULATION_READ_ONLY=true`.
Open <http://localhost:4322/app/>.
To stop any process listening on the application port and start a fresh Astro
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "recipe-book",
"name": "formulation",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recipe-book",
"name": "formulation",
"dependencies": {
"@astrojs/node": "^11.1.1",
"@astrojs/preact": "6.0.2",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "recipe-book",
"name": "formulation",
"private": true,
"type": "module",
"engines": {
@@ -0,0 +1,20 @@
import type { APIRoute } from "astro";
import { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser";
import { readOnlyMode } from "../../../../../lib/runtime";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
if (readOnlyMode) return Response.json({ error: "Ingredient parsing is unavailable in read-only mode." }, { status: 403 });
try {
const body = await request.json() as { text?: unknown };
if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 });
if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 });
return Response.json(await parseIngredientsWithOllama(body.text));
} catch (error) {
const message = error instanceof Error && error.name === "TimeoutError"
? "Ingredient parser timed out. Try again."
: error instanceof Error ? error.message : "Unable to parse ingredients.";
return Response.json({ error: message }, { status: 502 });
}
};
+14 -8
View File
@@ -34,9 +34,11 @@ if (Astro.request.method === "POST") {
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
}
if (form.get("intent") === "duplicate") {
const redirectTo = `/app/recipes/${duplicateRecipe(database, id)}/?edit=1`;
database.close();
return Astro.redirect(redirectTo, 303);
try {
return Astro.redirect(`/app/recipes/${duplicateRecipe(database, id)}/?edit=1`, 303);
} finally {
database.close();
}
}
if(form.get("intent")==="auto_yield"){
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
@@ -108,7 +110,10 @@ if (!recipe) { database.close(); return new Response("Recipe not found", { statu
const units = database.prepare("SELECT id, name, symbol, dimension FROM units ORDER BY dimension, name").all() as Array<{ id: string; name: string; symbol: string; dimension: string }>;
const structure = recipeStructure(database, id)!;
const autoYield=Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as {auto_yield:number}).auto_yield);
const ingredientOptions = database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>;
const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((ingredient) => ({
...ingredient,
aliases: (database.prepare("SELECT name FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name").all(ingredient.id) as Array<{name:string}>).map((entry) => entry.name),
}));
const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>;
const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>;
const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
@@ -122,7 +127,8 @@ const ingredientMap = new Map(projection.ingredients.map((entry) => [entry.id, e
const unitMap = new Map(projection.units.map((entry) => [entry.id, entry]));
const nutrition = calculateNutrition(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, mappings:new Map(projection.sourceMappings.map((entry) => [entry.id, entry])) });
const cost = calculateCost(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, purchaseItems:new Map(projection.purchaseItems.map((entry) => [entry.id, entry])), prepActions:new Map(projection.prepActions.map((entry) => [entry.id, entry])) });
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items:component.items.map((item) => { const ingredient="ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child="recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return { ...item, basisMember:item.basis_member, label:ingredient?.name ?? child?.title ?? "Unknown", href:ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, measureConversions:ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
const reviewedNutritionMappingIds=new Set(projection.sourceMappings.filter((mapping)=>mapping.mapping_type==="nutrition"&&mapping.status==="reviewed"&&Object.keys(mapping.nutrition_per_100g??{}).length>0).map((mapping)=>mapping.id));
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items:component.items.map((item) => { const ingredient="ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child="recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; const mapped=ingredient?(ingredient.nutrition_mapping_ids??[]).some((mappingId)=>reviewedNutritionMappingIds.has(mappingId)):true; return { ...item, basisMember:item.basis_member, label:ingredient?.name ?? child?.title ?? "Unknown", href:ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, attention:Boolean(ingredient&&!mapped),attentionMessage:ingredient&&!mapped?"Nutrition mapping needed":undefined, measureConversions:ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
const percentSubjectEntries=domainRecipe.components.flatMap(component=>component.items).map(item=>{const ingredient="ingredient_id" in item.reference?ingredientMap.get(item.reference.ingredient_id):undefined,child="recipe_id" in item.reference?recipeMap.get(item.reference.recipe_id):undefined;return ingredient?[`ingredient:${ingredient.id}`,{key:`ingredient:${ingredient.id}`,value:ingredient}] as [string,{key:string,value:Ingredient|Recipe}]:[`recipe:${child?.id}`,{key:`recipe:${child?.id}`,value:child!}] as [string,{key:string,value:Ingredient|Recipe}];});
const percentSubjects=[...new Map<string,{key:string,value:Ingredient|Recipe}>(percentSubjectEntries).values()];
const weightRates=Object.fromEntries(percentSubjects.flatMap(subject=>projection.units.map(unit=>{try{return [`${subject.key}:${unit.id}`,convertWithIngredientMeasures({quantity:1,unit_id:unit.id},"gram",subject.value as Ingredient,unitMap).quantity];}catch{return [`${subject.key}:${unit.id}`,null];}})));
@@ -160,18 +166,18 @@ const saved = Astro.url.searchParams.get("saved") === "1";
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}><input type="hidden" name="intent" value="auto_yield"/><button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button><span>Auto calculate total yield</span>{autoYield&&<small><b>Revert</b> to original and disable auto calculate</small>}</form>
</section>
<section id="structure" class="recipe-structure-workspace unified-recipe-editor"><RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/></section>
<section id="costing" class="recipe-edit-tab-panel" data-edit-recipe-panel="costing"><LiveCostValues client:load cost={cost}/></section>
<section id="costing" class="recipe-edit-tab-panel" data-edit-recipe-panel="costing"><LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable/></section>
<section id="equivalencies" class="recipe-equivalence-editor recipe-edit-tab-panel" data-edit-recipe-panel="equivalencies"><h2>UoM Equivalency</h2><p>Define how this finished recipe converts between weight, volume, and portions.</p>{recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>)}<form method="post"><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><span>=</span><input name="to_quantity" type="number" min="0.0001" step="any"/><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><input name="notes" placeholder="Notes"/><button name="intent" value="conversion">Add equivalency</button></form></section>
<section id="nutrition" class="recipe-edit-nutrition recipe-edit-tab-panel" data-edit-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable saveVersion={recipe.save_version}/></section>
<section id="additional" class="recipe-additional-editor"><h2>Additional Details</h2><form id="recipe-additional-form"><label class="cover-media-field"><span>{additional.cover_media_url?"Replace Cover Image":"Add Cover Image"}</span>{additional.cover_media_url&&<img src={additional.cover_media_url} alt=""/>}<input form="recipe-additional-form" name="cover_media_url" type="url" value={additional.cover_media_url??""} placeholder="Paste image URL"/></label><fieldset><legend>Shelf Life</legend><input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/><select name="shelf_unit"><option value="">Unit</option>{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}</select><input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/></fieldset><label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label><label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label></form></section>
</>:<><section id="structure" class="recipe-view-workspace"><div class="recipe-view-formula"><RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false}/></div><div class="recipe-view-details"><section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method"><h2>Prep Method <small>{domainRecipe.steps.length}</small></h2><ol>{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=><figure class="step-media">{entry.media_type==="image"?<img src={entry.url} alt={entry.caption??""}/>:<video src={entry.url} controls/>}{entry.caption&&<figcaption>{entry.caption}</figcaption>}</figure>)}</span></li>)}</ol></section><section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies"><h2>UoM Equivalency</h2>{recipeConversions.length?recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>):<p>No recipe-level equivalencies have been defined.</p>}</section><section id="costing" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing"><LiveCostValues client:load cost={cost}/></section><section id="nutrition" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients}/></section></div></section><section class="recipe-additional-view">{additional.cover_media_url&&<figure><img src={additional.cover_media_url} alt="" loading="lazy"/></figure>}<div><h2>Additional details</h2>{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}</div></section></>}
</>:<><section id="structure" class="recipe-view-workspace"><div class="recipe-view-formula"><RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false}/></div><div class="recipe-view-details"><section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method"><h2>Prep Method <small>{domainRecipe.steps.length}</small></h2><ol>{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=><figure class="step-media">{entry.media_type==="image"?<img src={entry.url} alt={entry.caption??""}/>:<video src={entry.url} controls/>}{entry.caption&&<figcaption>{entry.caption}</figcaption>}</figure>)}</span></li>)}</ol></section><section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies"><h2>UoM Equivalency</h2>{recipeConversions.length?recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>):<p>No recipe-level equivalencies have been defined.</p>}</section><section id="costing" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing"><LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id}/></section><section id="nutrition" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients}/></section></div></section><section class="recipe-additional-view">{additional.cover_media_url&&<figure><img src={additional.cover_media_url} alt="" loading="lazy"/></figure>}<div><h2>Additional details</h2>{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}</div></section></>}
</section>
{!editing&&<script is:inline>document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.remove('active'));document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-recipe-panel="${button.dataset.recipeTab}"]`)?.classList.add('active');}));const recipeHash=location.hash.slice(1);if(recipeHash)document.querySelector(`[data-recipe-tab="${recipeHash}"]`)?.click();</script>}
{editing&&<script is:inline>
let recipeDirty=false;
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
const setupRecipeEditor=()=>{const method=document.querySelector('.method-editor');if(!method||method.dataset.tabsReady)return;method.dataset.tabsReady='1';const coverSlot=method.querySelector('#recipe-cover-slot'),additionalSlot=method.querySelector('#recipe-additional-slot'),panelSlot=method.querySelector('#recipe-tab-panel-slot');const prepChildren=[method.querySelector(':scope > h2'),method.querySelector(':scope > ol'),method.querySelector(':scope > button')].filter(Boolean);const panels=[...document.querySelectorAll('[data-edit-recipe-panel]')];const additional=document.querySelector('#additional');const cover=additional?.querySelector('.cover-media-field');if(cover&&coverSlot)coverSlot.append(cover);panels.forEach(panel=>{panel.hidden=true;panelSlot?.append(panel)});if(additional&&additionalSlot)additionalSlot.append(additional);document.querySelectorAll('[data-edit-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{const selected=button.dataset.editRecipeTab;document.querySelectorAll('[data-edit-recipe-tab]').forEach(tab=>tab.classList.toggle('active',tab===button));prepChildren.forEach(child=>child.hidden=selected!=='method');if(coverSlot)coverSlot.hidden=selected!=='method';if(additionalSlot)additionalSlot.hidden=selected!=='method';if(panelSlot)panelSlot.hidden=selected==='method';panels.forEach(panel=>panel.hidden=panel.dataset.editRecipePanel!==selected)}));};
const setupRecipeEditor=async()=>{const method=document.querySelector('.method-editor');if(!method||method.dataset.tabsReady)return;method.dataset.tabsReady='1';const coverSlot=method.querySelector('#recipe-cover-slot'),additionalSlot=method.querySelector('#recipe-additional-slot'),panelSlot=method.querySelector('#recipe-tab-panel-slot'),panels=[...document.querySelectorAll('[data-edit-recipe-panel]')],additional=document.querySelector('#additional'),cover=additional?.querySelector('.cover-media-field'),prepChildren=[method.querySelector(':scope > h2'),method.querySelector(':scope > ol'),method.querySelector(':scope > button')].filter(Boolean);for(let frame=0;frame<120&&panels.some(panel=>[...panel.querySelectorAll('astro-island')].some(island=>island.hasAttribute('ssr')));frame++)await new Promise(requestAnimationFrame);if(cover&&coverSlot)coverSlot.append(cover);panels.forEach(panel=>{panel.hidden=true;panelSlot?.append(panel)});if(additional&&additionalSlot)additionalSlot.append(additional);document.querySelectorAll('[data-edit-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{const selected=button.dataset.editRecipeTab;document.querySelectorAll('[data-edit-recipe-tab]').forEach(tab=>tab.classList.toggle('active',tab===button));prepChildren.forEach(child=>child.hidden=selected!=='method');if(coverSlot)coverSlot.hidden=selected!=='method';if(additionalSlot)additionalSlot.hidden=selected!=='method';if(panelSlot)panelSlot.hidden=selected==='method';panels.forEach(panel=>panel.hidden=panel.dataset.editRecipePanel!==selected)}));};
document.addEventListener('recipe:editor-ready',setupRecipeEditor);
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
+19 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
import type { CalculatorComponent, CalculatorItem, Unit } from "../lib/types";
import { convert } from "../lib/measurement";
import type { NutritionResult } from "../lib/nutrition";
import type { CostResult } from "../lib/costing";
import type { CostLine, CostResult } from "../lib/costing";
import NutritionPanel from "./NutritionPanel";
import { number, roundForDisplay } from "../lib/format";
@@ -120,7 +120,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
return (
<tr key={item.id} class={item.basisMember ? "basis-row" : ""}>
<td>
{item.href ? <a href={item.href}>{item.label}</a> : item.label}
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}>!</span>}</span>
{item.optional && <span class="muted"> optional</span>}
{item.notes && <small>{item.notes}</small>}
</td>
@@ -169,10 +169,25 @@ function useLiveFactor() {
return factor;
}
export function LiveCostValues({ cost }: { cost: CostResult }) {
function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:CostLine; factor:number; currency:string; editable:boolean; expanded:boolean }) {
const money=new Intl.NumberFormat("en-US",{style:"currency",currency,minimumFractionDigits:2,maximumFractionDigits:4});
const href=line.kind==="ingredient"?`/app/ingredients/${line.subjectId}/${editable?"?edit=1#costs":"#costs"}`:`/app/recipes/${line.subjectId}/#costing`;
const body=<>
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":""}</span>
<a href={href}>{line.name}</a>
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete">!</span>}
<span class="cost-line-value">{line.cost!=null?money.format(line.cost*factor):line.kind==="ingredient"&&editable?<a href={href}>Add cost&nbsp; ✎</a>:""}</span>
</>;
if(line.purchase||line.children?.length)return <details class="cost-ledger-line" open={expanded}><summary>{body}</summary><div class="cost-line-detail">{line.purchase?<><div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div><div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div><div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div><div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div><div><small>Item ID #</small><strong>{line.purchase.sku??""}</strong></div><div><small>Vendor</small><strong>{line.purchase.supplier??""}</strong></div></>:<p>No usable purchase cost is available.</p>}{line.children?.length?<div class="cost-child-lines">{line.children.map(child=><CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>)}</div>:null}</div></details>;
return <div class="cost-ledger-line flat">{body}</div>;
}
export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=false }: { cost: CostResult; yieldQuantity?:number; yieldUnit?:string; editable?:boolean }) {
const factor=useLiveFactor();
const money=new Intl.NumberFormat("en-US",{style:"currency",currency:cost.currency,minimumFractionDigits:2,maximumFractionDigits:4});
return <section class="derived-card"><div class="derived-title"><h2>Recipe Cost</h2><strong>{Math.round(cost.completeness*100)}% priced</strong></div>{cost.batch!=null?<dl><div><dt>Scaled batch</dt><dd>{money.format(cost.batch*factor)}</dd></div>{cost.perServing!=null&&<div><dt>Per serving</dt><dd>{money.format(cost.perServing*factor)}</dd></div>}{cost.per100g!=null&&<div><dt>Per 100 g</dt><dd>{money.format(cost.per100g)}</dd></div>}</dl>:<p>No usable purchase prices are available yet.</p>}{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded.</p>}{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}</section>;
const [expansion,setExpansion]=useState({open:false,revision:0});
const setAll=(open:boolean)=>setExpansion((current)=>({open,revision:current.revision+1}));
return <section class="recipe-cost-ledger"><header><h2>Recipe Cost</h2><p>{editable?"Update an ingredients shared purchase cost here. The change is reflected in every recipe that uses it.":"Ingredient and sub-recipe costs used to calculate this recipe."}</p></header><div class="cost-ledger-heading"><span>Ingredient / Sub-Recipe <button type="button" onClick={()=>setAll(true)}>Expand all</button><i>|</i><button type="button" onClick={()=>setAll(false)}>Collapse all</button></span><span>Cost</span></div><div class={expansion.open?"cost-ledger-lines expand-all":"cost-ledger-lines"}>{cost.lines.map(line=><CostLedgerLine key={`${line.id}:${expansion.revision}`} line={line} factor={factor} currency={cost.currency} editable={editable} expanded={expansion.open}/>)}</div><div class="cost-summary"><div><strong>Total Yield</strong><span>{yieldQuantity!=null?number(yieldQuantity*factor):""} <small>{yieldUnit}</small></span></div><div><strong>Total Cost</strong><span>{cost.batch!=null?money.format(cost.batch*factor):""}</span></div><div><strong>Cost Per {yieldUnit.toUpperCase()}:</strong><span>{cost.batch!=null&&yieldQuantity?money.format(cost.batch/yieldQuantity):""}</span></div>{cost.perServing!=null&&<div><strong>Cost Per Serving</strong><span>{money.format(cost.perServing)}</span></div>}</div>{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded. {Math.round(cost.completeness*100)}% of ingredient weight is priced.</p>}{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}</section>;
}
export function LiveNutritionValues({ nutrition,servings,ingredients=[],editable=false,saveVersion }: { nutrition:NutritionResult; servings?:number; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) {
+336 -56
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "preact/hooks";
import type { RecipeStructure } from "../lib/database";
import { percentage, targetWeight } from "../lib/percentages";
type Option = { id: string; name: string };
type Option = { id: string; name: string; aliases?: string[] };
type Props = {
recipeId: string;
initial: RecipeStructure;
@@ -28,8 +28,18 @@ export default function RecipeStructureEditor({
weightRates = {},
}: Props) {
const [data, setData] = useState(initial);
const [choice, setChoice] = useState<Record<string, string>>({});
const [query, setQuery] = useState<Record<string, string>>({});
const [rowQuery, setRowQuery] = useState<Record<string, string>>({});
const [focusedItem, setFocusedItem] = useState<string>();
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
const [bulkError, setBulkError] = useState("");
const [bulkParsing, setBulkParsing] = useState(false);
const [prepOpen, setPrepOpen] = useState(false);
const [prepText, setPrepText] = useState("");
const [prepError, setPrepError] = useState("");
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
const [baseline, setBaseline] = useState(JSON.stringify(initial));
const [state, setState] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
@@ -111,9 +121,8 @@ export default function RecipeStructureEditor({
result.splice(to, 0, entry);
return result;
};
const addLine = (componentIndex: number) => {
const selected = choice[data.components[componentIndex].id];
if (!selected) return;
const addLine = (componentIndex: number, selected: string) => {
const componentId = data.components[componentIndex].id;
const [kind, id] = selected.split(":", 2);
updateComponent(componentIndex, (component) => ({
...component,
@@ -130,16 +139,181 @@ export default function RecipeStructureEditor({
optional: false,
nutrition_retention_factor: 1,
prep: [],
...(draftNotes[componentId]?.trim()
? { notes: draftNotes[componentId].trim() }
: {}),
},
],
}));
setChoice((current) => ({
...current,
[data.components[componentIndex].id]: "",
}));
setQuery((current) => ({ ...current, [data.components[componentIndex].id]: "" }));
setDraftNotes((current) => ({ ...current, [componentId]: "" }));
};
const normal = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
const replaceLineReference = (componentIndex: number, itemIndex: number, kind: "ingredient" | "recipe", id: string, name: string) => {
const itemId = data.components[componentIndex].items[itemIndex].id;
updateComponent(componentIndex, (component) => ({
...component,
items: component.items.map((line, index) => index === itemIndex ? {
...line,
ingredient_id: kind === "ingredient" ? id : undefined,
subrecipe_id: kind === "recipe" ? id : undefined,
} : line),
}));
setRowQuery((current) => ({ ...current, [itemId]: name }));
setFocusedItem(undefined);
};
const createPendingRowIngredient = (componentIndex: number, itemIndex: number, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
const existing = [...ingredients, ...pendingIngredients].find((ingredient) => normal(ingredient.name) === normal(trimmed));
if (existing) {
replaceLineReference(componentIndex, itemIndex, "ingredient", existing.id, existing.name);
return;
}
const takenIds = new Set([...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id));
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
let id = base;
let suffix = 2;
while (takenIds.has(id)) id = `${base}_${suffix++}`;
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
replaceLineReference(componentIndex, itemIndex, "ingredient", id, trimmed);
};
const createPendingIngredient = (componentIndex: number, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
const existing = [...ingredients, ...pendingIngredients].find(
(ingredient) => normal(ingredient.name) === normal(trimmed),
);
if (existing) {
addLine(componentIndex, `ingredient:${existing.id}`);
return;
}
const takenIds = new Set(
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id),
);
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
let id = base;
let suffix = 2;
while (takenIds.has(id)) id = `${base}_${suffix++}`;
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
addLine(componentIndex, `ingredient:${id}`);
};
const identityTokens = (value: string) => normal(value).split(" ").filter(Boolean).map((token) => token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token);
const unitAliases: Record<string, string> = {
g: "gram", gram: "gram", grams: "gram", kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
oz: "ounce_mass", ounce: "ounce_mass", ounces: "ounce_mass", lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
tsp: "teaspoon_us", teaspoon: "teaspoon_us", teaspoons: "teaspoon_us", tbsp: "tablespoon_us", tablespoon: "tablespoon_us", tablespoons: "tablespoon_us",
c: "cup_us", cup: "cup_us", cups: "cup_us", ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
l: "liter", liter: "liter", liters: "liter", ea: "each", each: "each", clove: "each", cloves: "each",
};
const addBulkIngredients = async () => {
if (!bulkText.trim()) { setBulkError("Enter at least one ingredient."); return; }
setBulkParsing(true); setBulkError("");
let parsed: { components: Array<{ name: string; items: Array<{ source_line:string;quantity:number|null;unit:string|null;ingredient:string;preparation:string|null;note:string|null;optional:boolean;alternatives:string[] }> }>; warnings:string[] };
try {
const response = await fetch("/api/app/recipes/parse-ingredients", { method:"POST", headers:{"content-type":"application/json"}, body:JSON.stringify({text:bulkText}) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? "Unable to parse ingredients.");
parsed = result;
} catch (error) {
setBulkError(error instanceof Error ? error.message : "Unable to parse ingredients.");
setBulkParsing(false); return;
}
const available = [
...[...ingredients,...pendingIngredients].map((item) => ({ ...item, kind: "ingredient" as const })),
...recipes.filter((item) => item.id !== recipeId).map((item) => ({ ...item, kind: "recipe" as const })),
];
const components: RecipeStructure["components"] = [];
const unmatched: string[] = [];
const created: Option[] = [];
const takenIds = new Set([...ingredients,...pendingIngredients].map((item) => item.id));
const newIngredient = (name:string) => {
const base=normal(name).replace(/ /g,"_")||"imported_ingredient";
let id=base,suffix=2;while(takenIds.has(id))id=`${base}_${suffix++}`;
takenIds.add(id);
const ingredient={id,name:name.trim()};
created.push(ingredient);
const option={...ingredient,kind:"ingredient" as const};
available.push(option);
return option;
};
for (const parsedComponent of parsed.components) {
const component = { id: uid("component"), name: parsedComponent.name || "Main", items: [], notes: [] } as RecipeStructure["components"][number];
components.push(component);
for (const parsedItem of parsedComponent.items) {
const wanted = normal(parsedItem.ingredient);
const tokens = identityTokens(wanted);
const reducedTokens = tokens.filter((token) => !["fresh","dried","flake","leave","chopped","minced","sliced","crushed","granulated"].includes(token));
const labels = (item: typeof available[number]) => [item.name, ...(item.aliases ?? [])].map(normal);
const labelTokens = (item: typeof available[number]) => labels(item).map(identityTokens);
const match = available.find((item) => labels(item).includes(wanted))
?? available.find((item) => labels(item).some((label) => label.includes(wanted) || wanted.includes(label)))
?? available.find((item) => labelTokens(item).some((label) => tokens.every((token) => label.includes(token))))
?? available.find((item) => reducedTokens.length && labelTokens(item).some((label) => reducedTokens.every((token) => label.includes(token))))
?? newIngredient(parsedItem.ingredient);
const unitId = parsedItem.unit ? unitAliases[normal(parsedItem.unit)] : "each";
if (!match || !unitId || !units.some((unit) => unit.id === unitId)) { unmatched.push(parsedItem.source_line); continue; }
const notes = [parsedItem.preparation, parsedItem.note, parsedItem.alternatives.length ? `Alternatives: ${parsedItem.alternatives.join("; ")}` : null].filter(Boolean).join("; ");
component.items.push({
id: uid("line"),
...(match.kind === "ingredient" ? { ingredient_id: match.id } : { subrecipe_id: match.id }),
quantity: parsedItem.quantity ?? 1, unit_id: unitId, basis_member: false, optional: parsedItem.optional,
nutrition_retention_factor: 1, prep: [], ...(notes ? { notes } : {}),
});
}
}
const populated = components.filter((entry) => entry.items.length > 0);
if (unmatched.length || !populated.length) {
setBulkError(unmatched.length ? `Could not match: ${unmatched.join("; ")}` : "No ingredients could be matched.");
setBulkParsing(false);
return;
}
setPendingIngredients((current) => [...current,...created]);
setData((current) => {
const additions = [...populated];
const currentComponents = current.components.map((component) => ({ ...component, items:[...component.items] }));
if (additions[0]?.name.toLowerCase() === "main" && currentComponents.length) currentComponents.at(-1)!.items.push(...additions.shift()!.items);
return { ...current, components: [...currentComponents, ...additions] };
});
const creationMessage=created.length?`${created.length} new canonical ingredient${created.length===1?" is":"s are"} pending (${created.map((item)=>item.name).join(", ")}) and will be created when you save. `:"";
setMessage(`${creationMessage}${parsed.warnings.length ? `Parser warnings: ${parsed.warnings.join(" ")}` : "Review imported ingredients before saving."}`);
setBulkText(""); setBulkError(""); setBulkOpen(false); setBulkParsing(false);
};
const addBulkPrepSteps = () => {
const lines = prepText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
if (!lines.length) {
setPrepError("Field is required");
return;
}
const additions = lines.map((instruction) => ({
id: uid("step"),
instruction,
equipment_ids: [],
}));
setData((current) => ({
...current,
steps: current.steps.length === 1 && !current.steps[0].instruction.trim()
? additions
: [...current.steps, ...additions],
}));
setPrepText("");
setPrepError("");
setPrepOpen(false);
};
const save = async () => {
const unresolvedRow = data.components.flatMap((component) => component.items).find((item) => {
const typed = rowQuery[item.id];
if (typed == null) return false;
const currentLabel = item.ingredient_id
? [...ingredients, ...pendingIngredients].find((entry) => entry.id === item.ingredient_id)?.name
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
return normal(typed) !== normal(currentLabel ?? "");
});
if (unresolvedRow) {
setState("error");
setMessage("Choose a search result or create the unmatched ingredient before saving.");
return false;
}
setState("saving");
setMessage("");
const detailsForm = document.querySelector<HTMLFormElement>("#recipe-details-form");
@@ -151,6 +325,7 @@ export default function RecipeStructureEditor({
const shelfUnit=String(additional?.get("shelf_unit")??"").trim();
const payload = {
...data,
new_ingredients: pendingIngredients.map(({id,name}) => ({id,name})),
...(details ? { metadata: {
title: String(details.get("title") ?? "").trim(),
yield_quantity: Number(details.get("yield_quantity")),
@@ -182,6 +357,7 @@ export default function RecipeStructureEditor({
return false;
}
setData(result);
setPendingIngredients([]);
setBaseline(JSON.stringify(result));
const saveVersionInput = document.querySelector<HTMLInputElement>(
'#recipe-details-form input[name="save_version"]',
@@ -199,13 +375,29 @@ export default function RecipeStructureEditor({
};
document.addEventListener("recipe:save-structure", handleDone);
return () => document.removeEventListener("recipe:save-structure", handleDone);
}, [data, calculatePercent, percentMode]);
}, [data, pendingIngredients, calculatePercent, percentMode]);
useEffect(() => {
document.dispatchEvent(new CustomEvent("recipe:editor-ready"));
}, []);
useEffect(() => {
document.dispatchEvent(new CustomEvent("recipe:dirty", { detail: JSON.stringify(data) !== baseline }));
}, [data, baseline]);
if (!bulkOpen && !prepOpen) return;
const previousOverflow = document.body.style.overflow;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setBulkOpen(false);
setPrepOpen(false);
}
};
document.body.style.overflow = "hidden";
document.addEventListener("keydown", closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", closeOnEscape);
};
}, [bulkOpen, prepOpen]);
useEffect(() => {
document.dispatchEvent(new CustomEvent("recipe:dirty", { detail: JSON.stringify(data) !== baseline || pendingIngredients.length > 0 }));
}, [data, baseline, pendingIngredients]);
useEffect(() => {
if (!autoYield) return;
const quantity=calculatedYieldWeight();
@@ -311,27 +503,39 @@ export default function RecipeStructureEditor({
{calculatePercent && percentMode === "bakers" && (
<th class="base-column">Base</th>
)}
{calculatePercent && <th>%</th>}
<th>Qty</th>
<th>Unit</th>
<th>Ingredient / Recipe</th>
<th>Notes</th>
<th></th>
{calculatePercent && <th class="percent-column">%</th>}
<th class="quantity-column">Qty</th>
<th class="unit-column">Unit</th>
<th class="entity-column">Ingredient / Recipe</th>
<th class="notes-column">Notes</th>
<th class="actions-column"></th>
</tr>
</thead>
<tbody>
{component.items.map((item, itemIndex) => {
const isPending = Boolean(
item.ingredient_id &&
pendingIngredients.some(
(entry) => entry.id === item.ingredient_id,
),
);
const label = item.ingredient_id
? ingredients.find(
? [...ingredients,...pendingIngredients].find(
(entry) => entry.id === item.ingredient_id,
)?.name
: recipes.find((entry) => entry.id === item.subrecipe_id)
?.name;
const href = item.ingredient_id
? `/app/ingredients/${item.ingredient_id}/`
: `/app/recipes/${item.subrecipe_id}/`;
const typedValue = rowQuery[item.id] ?? label ?? "";
const choices = [
...[...ingredients, ...pendingIngredients].map((entry) => ({ ...entry, kind: "ingredient" as const })),
...recipes.filter((entry) => entry.id !== recipeId).map((entry) => ({ ...entry, kind: "recipe" as const })),
];
const exactMatch = choices.some((entry) => [entry.name, ...(entry.aliases ?? [])].some((name) => normal(name) === normal(typedValue)));
const shownChoices = choices.filter((entry) => !typedValue.trim() || [entry.name, ...(entry.aliases ?? [])].some((name) => normal(name).includes(normal(typedValue)))).slice(0, 10);
const hasUncommittedInput = normal(typedValue) !== normal(label ?? "");
const canCreateInput = Boolean(typedValue.trim()) && !exactMatch;
return (
<tr class={dragging?.kind === "item" && dragging.component === componentIndex && dragging.index === itemIndex ? "dragging" : ""} key={item.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
<tr class={`${dragging?.kind === "item" && dragging.component === componentIndex && dragging.index === itemIndex ? "dragging" : ""}${isPending ? " pending-ingredient-row" : ""}`} key={item.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.stopPropagation();
if (dragging?.kind === "item" && dragging.component === componentIndex) updateComponent(componentIndex, (value) => ({ ...value, items: move(value.items, dragging.index, itemIndex) }));
setDragging(undefined);
@@ -360,7 +564,7 @@ export default function RecipeStructureEditor({
</td>
)}
{calculatePercent && (
<td>
<td class="percent-column">
{percent(item) == null ? (
<span title="A weight equivalency is required">
@@ -388,7 +592,7 @@ export default function RecipeStructureEditor({
)}
</td>
)}
<td>
<td class="quantity-column">
<input
type="number"
min="0.0001"
@@ -411,7 +615,7 @@ export default function RecipeStructureEditor({
}
/>
</td>
<td>
<td class="unit-column">
<select
value={item.unit_id}
onChange={(event) =>
@@ -435,17 +639,38 @@ export default function RecipeStructureEditor({
))}
</select>
</td>
<td>
<a class="editor-entity-link" href={href}>
<strong>
{label ?? item.ingredient_id ?? item.subrecipe_id}
</strong>
</a>
<small>
{item.subrecipe_id ? "Sub-recipe" : "Ingredient"}
</small>
<td class="entity-column">
<div class={`row-entity-combobox${hasUncommittedInput ? " unmatched" : ""}`}>
<input
type="text"
value={typedValue}
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
aria-expanded={focusedItem === item.id}
aria-autocomplete="list"
onFocus={(event) => { setFocusedItem(item.id); event.currentTarget.select(); }}
onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)}
onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))}
/>
{isPending && <span class="ingredient-attention-icon" aria-label="New unmatched ingredient" title="New ingredient; details can be completed after saving">!</span>}
{focusedItem === item.id && (
<div class="row-entity-results" role="listbox">
{shownChoices.map((entry) => (
<button type="button" role="option" onMouseDown={(event) => event.preventDefault()} onClick={() => replaceLineReference(componentIndex, itemIndex, entry.kind, entry.id, entry.name)}>
<strong>{entry.name}</strong><small>{entry.kind === "recipe" ? "Recipe" : "Ingredient"}</small>
</button>
))}
{canCreateInput && (
<div class="row-create-actions">
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>Create New Ingredient</button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>Create Ingredient w/ Prep Action</button>
</div>
)}
</div>
)}
</div>
{isPending && <small class="pending-ingredient-help">New ingredient · created on save</small>}
</td>
<td>
<td class="notes-column">
<input
class="line-notes"
aria-label={`${label} notes`}
@@ -463,7 +688,7 @@ export default function RecipeStructureEditor({
}
/>
</td>
<td>
<td class="actions-column">
<span class="row-actions">
<button
class="remove-row"
@@ -496,24 +721,39 @@ export default function RecipeStructureEditor({
onInput={(event) => {
const value=event.currentTarget.value;
setQuery((current) => ({ ...current, [component.id]: value }));
setChoice((current) => ({ ...current, [component.id]: "" }));
}}
/>
{(query[component.id]?.trim() ?? "") && !choice[component.id] && (
{(query[component.id]?.trim() ?? "") && (
<div class="recipe-item-results">
{[
...ingredients.map((item) => ({...item,kind:"ingredient" as const})),
...[...ingredients,...pendingIngredients].map((item) => ({...item,kind:"ingredient" as const})),
...recipes.filter((item) => item.id !== recipeId).map((item) => ({...item,kind:"recipe" as const})),
].filter((item) => item.name.toLowerCase().includes(query[component.id].trim().toLowerCase())).slice(0,8).map((item) => (
].filter((item) => [item.name,...(item.aliases??[])].some((name) => name.toLowerCase().includes(query[component.id].trim().toLowerCase()))).slice(0,8).map((item) => (
<button onClick={() => {
setChoice((current) => ({...current,[component.id]:`${item.kind}:${item.id}`}));
setQuery((current) => ({...current,[component.id]:item.name}));
addLine(componentIndex, `${item.kind}:${item.id}`);
}}><strong>{item.name}</strong><small>{item.kind === "recipe" ? "Recipe" : "Ingredient"}</small></button>
))}
{!ingredients.some((item) => normal(item.name) === normal(query[component.id])) &&
!pendingIngredients.some((item) => normal(item.name) === normal(query[component.id])) && (
<button
type="button"
class="create-ingredient-result"
onClick={() => createPendingIngredient(componentIndex, query[component.id])}
>
<strong>+ Create “{query[component.id].trim()}”</strong>
<small>New ingredient</small>
</button>
)}
</div>
)}
</div>
<button disabled={!choice[component.id]} onClick={() => addLine(componentIndex)}>Add Ingredients</button>
<input
class="add-line-notes"
value={draftNotes[component.id] ?? ""}
placeholder="Add notes"
aria-label={`Notes for the new item in ${component.name}`}
onInput={(event) => setDraftNotes((current) => ({ ...current, [component.id]: event.currentTarget.value }))}
/>
</div>
</fieldset>
))}
@@ -561,14 +801,62 @@ export default function RecipeStructureEditor({
<span aria-hidden="true"></span>
Add Note
</button>
<button class="bulk-add-trigger" type="button" onClick={() => { setBulkError(""); setBulkOpen(true); }}>
Add Ingredients
</button>
</div>
{bulkOpen && (
<div class="bulk-ingredient-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setBulkOpen(false); }}>
<section class="bulk-ingredient-dialog" role="dialog" aria-modal="true" aria-labelledby="bulk-ingredient-title">
<button class="bulk-dialog-close" type="button" aria-label="Close" onClick={() => setBulkOpen(false)}>×</button>
<h2 id="bulk-ingredient-title">Add Ingredients</h2>
<p>Type or copy/paste ingredients from a document, spreadsheet, PDF, or website.</p>
<textarea
autoFocus
value={bulkText}
onInput={(event) => { setBulkText(event.currentTarget.value); setBulkError(""); }}
placeholder={'Dry Mix:\n500g flour\n1/2 cup semolina\nsalt to taste\n\nWet:\n5 cloves garlic\n3 egg yolks\nolive oil (room temp)'}
/>
{bulkError && <p class="bulk-dialog-error">{bulkError}</p>}
<div class="bulk-entry-help"><span>Add headers <small>using a colon : eg To Garnish:</small></span><span>Add notes to ingredients <small>by putting them in (notes)</small></span></div>
<footer><button type="button" class="bulk-cancel" disabled={bulkParsing} onClick={() => setBulkOpen(false)}>Cancel</button><button type="button" class="bulk-submit" disabled={!bulkText.trim() || bulkParsing} onClick={addBulkIngredients}>{bulkParsing ? "Parsing…" : "Add Ingredients"}</button></footer>
</section>
</div>
)}
{prepOpen && (
<div class="bulk-ingredient-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setPrepOpen(false); }}>
<section class="bulk-ingredient-dialog bulk-prep-dialog" role="dialog" aria-modal="true" aria-labelledby="bulk-prep-title">
<button class="bulk-dialog-close" type="button" aria-label="Close" onClick={() => setPrepOpen(false)}>×</button>
<h2 id="bulk-prep-title">Add Prep Steps</h2>
<p>Type or copy/paste prep steps from a document, spreadsheet, PDF, or website.</p>
<textarea
autoFocus
value={prepText}
onInput={(event) => { setPrepText(event.currentTarget.value); setPrepError(""); }}
placeholder={'Dry Mix:\nHeat oven to 350°F\nCombine dry ingredients in a medium bowl\n\nGarnish:\nAdd chopped chocolate chips as garnish\n(note about plating)'}
/>
{prepError && <p class="bulk-dialog-error">{prepError}</p>}
<div class="bulk-entry-help">
<span>New line <small>represents a new prep step</small></span>
<span>Add headers <small>using a colon : eg To Garnish:</small></span>
<span>Add notes to prep method <small>by putting them in (notes)</small></span>
</div>
<footer>
<button type="button" class="bulk-cancel" onClick={() => setPrepOpen(false)}>Cancel</button>
<button type="button" class="bulk-submit" disabled={!prepText.trim()} onClick={addBulkPrepSteps}>Add Prep Steps</button>
</footer>
</section>
</div>
)}
{showMethod && (
<section class="method-editor">
<div id="recipe-cover-slot"></div>
<h2>Prep Method <small>{data.steps.length}</small></h2>
<ol>
{data.steps.map((step, index) => (
<li class={dragging?.kind === "step" && dragging.index === index ? "dragging" : ""} onDragOver={(event) => event.preventDefault()} onDrop={() => {
{data.steps.map((step, index) => {
const stepKind = step.instruction.trim().endsWith(":") ? " prep-heading" : /^\(.+\)$/.test(step.instruction.trim()) ? " prep-note" : "";
return (
<li class={`${dragging?.kind === "step" && dragging.index === index ? "dragging" : ""}${stepKind}`} onDragOver={(event) => event.preventDefault()} onDrop={() => {
if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) }));
setDragging(undefined);
}}>
@@ -604,19 +892,11 @@ export default function RecipeStructureEditor({
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "step", index })}>⠿</span>
</span>
</li>
))}
)})}
</ol>
<button
class="secondary-action"
onClick={() =>
setData((current) => ({
...current,
steps: [
...current.steps,
{ id: uid("step"), instruction: "", equipment_ids: [] },
],
}))
}
onClick={() => { setPrepError(""); setPrepOpen(true); }}
>
Add Prep Steps
</button>
+13
View File
@@ -37,6 +37,13 @@ describe("recipe costing", () => {
expect(result.perServing).toBeCloseTo(0.125);
expect(result.per100g).toBeCloseTo(0.25);
expect(result.completeness).toBe(1);
expect(result.lines).toMatchObject([{
subjectId: "flour",
kind: "ingredient",
cost: 0.5,
completeness: 1,
purchase: { id: "flour_bag", price: 5, currency: "USD" },
}]);
});
it("inflates purchased cost for prep loss", () => {
@@ -58,5 +65,11 @@ describe("recipe costing", () => {
const result = calculateCost(plate, catalogs([base, plate]));
expect(result.batch).toBeCloseTo(0.125);
expect(result.completeness).toBe(1);
expect(result.lines[0]).toMatchObject({
subjectId: "dough",
kind: "recipe",
cost: 0.125,
children: [{ subjectId: "flour", kind: "ingredient" }],
});
});
});
+45 -8
View File
@@ -10,6 +10,29 @@ export type CostResult = {
pricedWeightG: number;
completeness: number;
warnings: string[];
lines: CostLine[];
};
export type CostLine = {
id: string;
subjectId: string;
name: string;
kind: "ingredient" | "recipe";
cost?: number;
weightG?: number;
completeness: number;
purchase?: {
id: string;
name: string;
packageQuantity: number;
packageUnitId: string;
price: number;
currency: string;
effectiveAt: string;
supplier?: string;
sku?: string;
};
children?: CostLine[];
};
type Catalogs = {
@@ -46,12 +69,12 @@ function usableCostPerGram(ingredient: Ingredient, item: PurchaseItem, currency:
}
}
function ingredientRate(ingredient: Ingredient, catalogs: Catalogs, currency: string): number | undefined {
function ingredientCostSource(ingredient: Ingredient, catalogs: Catalogs, currency: string) {
return [...catalogs.purchaseItems.values()]
.filter((item) => item.ingredient_id === ingredient.id && item.status === "active")
.map((item) => usableCostPerGram(ingredient, item, currency, catalogs.units))
.filter((rate): rate is number => rate != null)
.sort((a, b) => a - b)[0];
.map((item) => ({ item, rate: usableCostPerGram(ingredient, item, currency, catalogs.units), price: latestPrice(item, currency) }))
.filter((entry): entry is typeof entry & { rate: number; price: NonNullable<typeof entry.price> } => entry.rate != null && entry.price != null)
.sort((a, b) => a.rate - b.rate)[0];
}
export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "USD", stack: string[] = []): CostResult {
@@ -60,6 +83,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
let inputWeightG = 0;
let pricedWeightG = 0;
const warnings: string[] = [];
const lines: CostLine[] = [];
for (const item of recipe.components.flatMap((component) => component.items)) {
if (item.optional) continue;
@@ -71,16 +95,23 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
usableWeightG = grams(item.amount.quantity, item.amount.unit_id, ingredient, catalogs.units);
} catch (error) {
warnings.push(`${ingredient.name}: ${(error as Error).message}`);
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", completeness:0 });
continue;
}
inputWeightG += usableWeightG;
const rate = ingredientRate(ingredient, catalogs, currency);
if (rate == null) {
const source = ingredientCostSource(ingredient, catalogs, currency);
if (!source) {
warnings.push(`${ingredient.name}: no active ${currency} purchase price with a convertible package size`);
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", weightG:usableWeightG, completeness:0 });
continue;
}
batch += (usableWeightG / prepYieldFactor(item, catalogs)) * rate;
const lineCost=(usableWeightG / prepYieldFactor(item, catalogs)) * source.rate;
batch += lineCost;
pricedWeightG += usableWeightG;
lines.push({
id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", cost:lineCost, weightG:usableWeightG, completeness:1,
purchase:{ id:source.item.id, name:source.item.name, packageQuantity:source.item.package.quantity, packageUnitId:source.item.package.unit_id, price:source.price.amount, currency:source.price.currency, effectiveAt:source.price.effective_at, supplier:source.item.supplier_id, sku:source.item.supplier_sku },
});
continue;
}
@@ -88,6 +119,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
if (!child) throw new Error(`Unknown sub-recipe: ${item.reference.recipe_id}`);
if (item.reference.component_id) {
warnings.push(`${child.title}: component-specific costing is not available for ${item.reference.component_id}`);
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0 });
continue;
}
const childResult = calculateCost(child, catalogs, currency, [...stack, recipe.id]);
@@ -96,6 +128,7 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
usedWeightG = convertWithIngredientMeasures(item.amount, "gram", { id: child.id, name: child.title, schema_version: 2, status: "active", categories: [],measure_conversions:child.measure_conversions }, catalogs.units).quantity;
} catch (error) {
warnings.push(`${child.title}: ${(error as Error).message}`);
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0, children:childResult.lines });
continue;
}
inputWeightG += usedWeightG;
@@ -104,11 +137,14 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
: undefined;
if (!childYieldG || childResult.batch == null) {
warnings.push(`${child.title}: sub-recipe cost requires a positive mass yield and at least one priced input`);
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", weightG:usedWeightG, completeness:0, children:childResult.lines });
continue;
}
const factor = usedWeightG / childYieldG;
batch += childResult.batch * factor / prepYieldFactor(item, catalogs);
const lineCost=childResult.batch * factor / prepYieldFactor(item, catalogs);
batch += lineCost;
pricedWeightG += usedWeightG * childResult.completeness;
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", cost:lineCost, weightG:usedWeightG, completeness:childResult.completeness, children:childResult.lines });
warnings.push(...childResult.warnings.map((warning) => `${child.title}: ${warning}`));
}
@@ -128,5 +164,6 @@ export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "US
pricedWeightG,
completeness: inputWeightG > 0 ? pricedWeightG / inputWeightG : 0,
warnings: [...new Set(warnings)],
lines,
};
}
+11 -1
View File
@@ -45,6 +45,7 @@ export function saveRecipeMetadata(database: DatabaseSync, id: string, expectedV
export type RecipeStructure = {
save_version: number;
new_ingredients?: Array<{ id: string; name: string }>;
metadata?: {
title: string; yield_quantity: number; yield_unit_id: string; yield_servings: number | null; yield_basis: string | null;
station?: string | null; cover_media_url?: string | null; tags?: string[];
@@ -70,6 +71,13 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
if (current.save_version !== structure.save_version) throw new Error("This recipe changed in another tab. Reload before saving.");
if (!structure.components.length) throw new Error("A recipe needs at least one component.");
if (!structure.steps.length) throw new Error("A recipe needs at least one preparation step.");
const newIngredients = new Map<string,string>();
for (const ingredient of structure.new_ingredients ?? []) {
const ingredientId=ingredient.id.trim(),name=ingredient.name.trim();
if(!/^[a-z0-9][a-z0-9_]*$/.test(ingredientId)||!name||newIngredients.has(ingredientId))throw new Error("Imported ingredients need unique names and stable IDs.");
if(database.prepare("SELECT 1 FROM ingredients WHERE id=?").get(ingredientId))throw new Error(`Ingredient ${ingredientId} already exists. Reload and try again.`);
newIngredients.set(ingredientId,name);
}
if (structure.metadata) {
if (!structure.metadata.title.trim()) throw new Error("Recipe name is required.");
if (!Number.isFinite(structure.metadata.yield_quantity) || structure.metadata.yield_quantity <= 0) throw new Error("Total yield must be greater than zero.");
@@ -90,7 +98,7 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
if (!Number.isFinite(item.quantity) || item.quantity <= 0) throw new Error(`${item.id} needs a positive quantity.`);
if(item.nutrition_retention_factor!=null&&(!Number.isFinite(item.nutrition_retention_factor)||item.nutrition_retention_factor<0||item.nutrition_retention_factor>1))throw new Error(`${item.id} nutrition retention must be between 0 and 1.`);
if (!database.prepare("SELECT 1 FROM units WHERE id = ?").get(item.unit_id)) throw new Error(`${item.id} uses an unknown unit.`);
if (item.ingredient_id && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
if (item.ingredient_id && !newIngredients.has(item.ingredient_id) && !database.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id)) throw new Error(`${item.id} references an unknown ingredient.`);
if (item.subrecipe_id && (!database.prepare("SELECT 1 FROM recipes WHERE id = ?").get(item.subrecipe_id) || item.subrecipe_id === id)) throw new Error(`${item.id} references an invalid sub-recipe.`);
}
}
@@ -102,6 +110,8 @@ export function saveRecipeStructure(database: DatabaseSync, id: string, structur
const nextVersion = current.save_version + 1;
database.exec("BEGIN IMMEDIATE");
try {
const ingredientInsert=database.prepare("INSERT INTO ingredients(id,schema_version,name,status,categories_json,tags_json,source_json) VALUES (?,2,?,'active','[]','[]',?)");
for(const [ingredientId,name] of newIngredients)ingredientInsert.run(ingredientId,name,JSON.stringify({source_type:"ai_import",title:"Recipe ingredient import",reviewed:false}));
database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(id);
database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(id);
const componentInsert = database.prepare("INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)");
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { normalizeIngredientText, validateIngredientParse } from "./ingredient-parser";
describe("ingredient parser", () => {
it("normalizes copied checklist text and Unicode fractions", () => {
expect(normalizeIngredientText("▢1 pound beef\n☐ ½ cup water\n▢salt, , to taste"))
.toBe("1 pound beef\n1/2 cup water\nsalt, to taste");
});
it("validates a structured parser response", () => {
expect(validateIngredientParse({
components: [{ name:"Main", items:[{
source_line:"1 pound beef", quantity:1, unit:"pound", ingredient:"ground beef",
preparation:null, note:null, optional:false, alternatives:[],
}] }], warnings:[],
}, "1 pound beef")).toMatchObject({ normalized_text:"1 pound beef", components:[{name:"Main"}] });
});
it("rejects invented or malformed quantities", () => {
expect(() => validateIngredientParse({
components: [{ name:"Main", items:[{
source_line:"salt", quantity:-1, unit:null, ingredient:"salt",
preparation:null, note:null, optional:false, alternatives:[],
}] }], warnings:[],
}, "salt")).toThrow("invalid quantity");
});
});
+186
View File
@@ -0,0 +1,186 @@
export type ParsedIngredient = {
source_line: string;
quantity: number | null;
unit: string | null;
ingredient: string;
preparation: string | null;
note: string | null;
optional: boolean;
alternatives: string[];
};
export type ParsedIngredientComponent = {
name: string;
items: ParsedIngredient[];
};
export type IngredientParseResult = {
normalized_text: string;
components: ParsedIngredientComponent[];
warnings: string[];
};
const FRACTIONS: Record<string, string> = {
"¼": "1/4", "½": "1/2", "¾": "3/4", "⅐": "1/7", "⅑": "1/9",
"⅒": "1/10", "⅓": "1/3", "⅔": "2/3", "⅕": "1/5", "⅖": "2/5",
"⅗": "3/5", "⅘": "4/5", "⅙": "1/6", "⅚": "5/6", "⅛": "1/8",
"⅜": "3/8", "⅝": "5/8", "⅞": "7/8",
};
export function normalizeIngredientText(value: string): string {
return value
.replace(/[¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, (value) => FRACTIONS[value] ?? value)
.normalize("NFKC")
.replace(//g, "/")
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
.split(/\r?\n/)
.map((line) => line
.replace(/^\s*(?:[▢□☐☑✓✔●•▪◦]|\[(?: |x|X)?\])\s*/, "")
.replace(/\s*,\s*,+/g, ",")
.replace(/[ \t]+/g, " ")
.replace(/\s+,/g, ",")
.trim())
.filter(Boolean)
.join("\n");
}
const responseSchema = {
type: "object",
additionalProperties: false,
required: ["components", "warnings"],
properties: {
components: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["name", "items"],
properties: {
name: { type: "string" },
items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["source_line", "quantity", "unit", "ingredient", "preparation", "note", "optional", "alternatives"],
properties: {
source_line: { type: "string" },
quantity: { type: ["number", "null"] },
unit: { type: ["string", "null"] },
ingredient: { type: "string" },
preparation: { type: ["string", "null"] },
note: { type: ["string", "null"] },
optional: { type: "boolean" },
alternatives: { type: "array", items: { type: "string" } },
},
},
},
},
},
},
warnings: { type: "array", items: { type: "string" } },
},
} as const;
const systemPrompt = `You are a purpose-built culinary ingredient parser. Convert normalized recipe ingredient text into JSON only.
Rules:
- Preserve the meaning and never invent an ingredient, amount, unit, or preparation.
- Convert fractions and mixed numbers to decimal quantities.
- Use singular conventional unit names such as gram, ounce, pound, teaspoon, tablespoon, cup, milliliter, liter, or each.
- A line ending in a colon is a component header. Use "Main" when there is no header.
- Group consecutive lines under one component. Do not create a new Main component for each line.
- ingredient contains only the ingredient identity, never its quantity, unit, size, preparation, or note. For example, "15 ounces tomato sauce" becomes quantity 15, unit "ounce", ingredient "tomato sauce".
- Treat sizes such as small, medium, and large as preparation or notes and use unit "each". For example, "1 medium onion, chopped" becomes quantity 1, unit "each", ingredient "onion", preparation "medium; chopped".
- Split a source line containing two independently required ingredients into two items, retaining the same source_line.
- Specifically, "salt and freshly ground black pepper, to taste" becomes separate salt and black pepper items.
- Keep alternatives in alternatives instead of adding them as required items.
- Put physical treatment such as chopped, minced, sliced, freshly ground, dried, or drained in preparation.
- Put serving instructions, "to taste", temperatures, and other qualifications in note.
- Set optional true when the source explicitly says optional.
- Use null quantity and unit when the source supplies none. Do not guess.
- Use null rather than an empty string. Do not repeat alternatives in note.
- Retain every source line. Add a warning for ambiguity.`;
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
export function validateIngredientParse(value: unknown, normalizedText: string): IngredientParseResult {
if (!value || typeof value !== "object") throw new Error("The parser returned an invalid document.");
const source = value as Record<string, unknown>;
if (!Array.isArray(source.components) || !Array.isArray(source.warnings)) throw new Error("The parser response is missing components or warnings.");
const components = source.components.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid component.");
const component = entry as Record<string, unknown>;
if (typeof component.name !== "string" || !component.name.trim() || !Array.isArray(component.items)) throw new Error("The parser returned an invalid component.");
const items = component.items.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("The parser returned an invalid ingredient.");
const item = entry as Record<string, unknown>;
if (typeof item.source_line !== "string") throw new Error("The parser returned an ingredient without its source line.");
if (typeof item.ingredient !== "string" || !item.ingredient.trim()) throw new Error(`The parser returned an unnamed ingredient for: ${item.source_line}`);
if (!(item.quantity === null || typeof item.quantity === "number" && Number.isFinite(item.quantity) && item.quantity > 0)) throw new Error(`The parser returned an invalid quantity for: ${item.source_line}`);
if (!isNullableString(item.unit) || !isNullableString(item.preparation) || !isNullableString(item.note)) throw new Error(`The parser returned invalid text fields for: ${item.source_line}`);
if (typeof item.optional !== "boolean" || !Array.isArray(item.alternatives) || !item.alternatives.every((value) => typeof value === "string")) throw new Error(`The parser returned invalid qualifications for: ${item.source_line}`);
const sourceLine = item.source_line.trim();
const sourceSaysOptional = /\boptional\b/i.test(sourceLine);
const alternatives = (item.alternatives as string[]).map((value) => value.trim()).filter((value) => value && !/^(?:none|optional)$/i.test(value));
const note = item.note?.trim() || null;
return {
source_line: sourceLine,
quantity: item.quantity as number | null,
unit: item.unit?.trim().toLowerCase() || null,
ingredient: item.ingredient.trim(),
preparation: item.preparation?.trim() || null,
note: note && (!/^optional$/i.test(note) || sourceSaysOptional) ? note : null,
optional: sourceSaysOptional,
alternatives,
} satisfies ParsedIngredient;
});
return { name: component.name.trim(), items };
}).filter((component) => component.items.length > 0);
if (!components.length) throw new Error("The parser did not find any ingredients.");
if (!source.warnings.every((value) => typeof value === "string")) throw new Error("The parser returned invalid warnings.");
const consolidated: ParsedIngredientComponent[] = [];
for (const component of components) {
const previous = consolidated.at(-1);
if (previous?.name.toLowerCase() === component.name.toLowerCase()) previous.items.push(...component.items);
else consolidated.push(component);
}
return { normalized_text: normalizedText, components: consolidated, warnings: source.warnings as string[] };
}
export async function parseIngredientsWithOllama(text: string): Promise<IngredientParseResult> {
const normalizedText = normalizeIngredientText(text);
if (!normalizedText) throw new Error("Enter at least one ingredient.");
const endpoint = process.env.FORMULATION_OLLAMA_URL ?? "http://10.0.10.211:11434/api/chat";
const model = process.env.FORMULATION_INGREDIENT_PARSER_MODEL ?? "qwen3:4b-instruct";
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
signal: AbortSignal.timeout(90_000),
body: JSON.stringify({
model,
stream: false,
think: false,
format: responseSchema,
options: { temperature: 0, num_predict: 6000 },
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: normalizedText },
],
}),
});
if (!response.ok) throw new Error(`Ingredient parser service failed (${response.status}).`);
const payload = await response.json() as { message?: { content?: string } };
const content = payload.message?.content?.trim();
if (!content) throw new Error("Ingredient parser returned an empty response.");
let parsed: unknown;
const unwrapped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const firstBrace = unwrapped.indexOf("{");
const lastBrace = unwrapped.lastIndexOf("}");
const json = firstBrace >= 0 && lastBrace > firstBrace ? unwrapped.slice(firstBrace, lastBrace + 1) : unwrapped;
try { parsed = JSON.parse(json); }
catch { throw new Error("Ingredient parser returned malformed JSON."); }
return validateIngredientParse(parsed, normalizedText);
}
+2
View File
@@ -167,6 +167,8 @@ export type CalculatorItem = {
id: string;
label: string;
href?: string;
attention?: boolean;
attentionMessage?: string;
amount: Amount;
percentage?: number;
basisMember?: boolean;
+170
View File
@@ -421,6 +421,39 @@ th { font-size:.7rem; letter-spacing:.06em; }
.application-body .retention-input { display:flex; align-items:center; gap:4px; }.application-body .retention-input input { width:68px; }
.application-body .auto-yield-action { display:grid; grid-template-columns:1fr auto auto; align-items:center; gap:12px; width:48%; margin-left:52%; padding:18px 32px; border-left:1px solid #eeeef3; border-top:1px solid #eeeef3; }.application-body .auto-yield-action button { padding:8px 12px; color:#fff; background:#3d5df6; border:0; }.application-body .auto-yield-action small { grid-column:1/-1; color:#a5a9c1; }
.application-body .cost-diagnostics { margin-top:18px; padding:12px; color:#8a4b25; background:#fff8f2; }.application-body .cost-diagnostics summary { cursor:pointer; font-weight:600; }.application-body .cost-diagnostics ul { padding-left:20px; }
.recipe-cost-ledger { color:#050841; }
.recipe-cost-ledger > header { margin-bottom:26px; }
.recipe-cost-ledger > header h2 { margin:0 0 6px !important; font-size:24px; }
.recipe-cost-ledger > header p { margin:0; color:#8d92a2; font-size:14px; line-height:1.5; }
.cost-ledger-heading { display:grid; grid-template-columns:minmax(0,1fr) 130px; gap:18px; padding:10px 18px; color:#a0a7bd; border-bottom:1px solid #eeeef3; font-size:12px; font-weight:600; }
.cost-ledger-heading > span:last-child { text-align:right; }
.cost-ledger-heading button { padding:0 3px; color:#4560ff; background:transparent; border:0; font:inherit; cursor:pointer; }
.cost-ledger-heading i { color:#a0a7bd; font-style:normal; }
.cost-ledger-line { border-bottom:1px solid #eeeef3; background:#fff; }
.cost-ledger-line > summary,.cost-ledger-line.flat { display:grid; grid-template-columns:24px 30px minmax(0,1fr) 22px 130px; align-items:center; gap:10px; min-height:66px; padding:10px 18px; list-style:none; }
.cost-ledger-line > summary::-webkit-details-marker { display:none; }
.cost-ledger-line > summary { cursor:pointer; }
.cost-ledger-line > summary::before { content:"⌄"; grid-column:1; color:#101a4a; font-size:20px; transform:rotate(-90deg); transition:.12s; }
.cost-ledger-line[open] > summary::before { transform:none; }
.cost-ledger-line.flat { grid-template-columns:24px 30px minmax(0,1fr) 22px 130px; }
.cost-ledger-line.flat::before { content:""; }
.cost-subject-icon { display:grid; grid-column:2; place-items:center; width:28px; height:28px; color:#fff; background:#54a19a; border-radius:50%; font-size:10px; }
.cost-subject-icon.recipe { background:#6771a7; }
.cost-ledger-line a { min-width:0; overflow:hidden; color:#050841; text-decoration:none; text-overflow:ellipsis; white-space:nowrap; }
.cost-attention { display:grid; place-items:center; width:17px; height:17px; color:#f43d4b; border:1.5px solid currentColor; border-radius:50%; font-size:11px; font-weight:700; }
.cost-line-value { grid-column:5; text-align:right; white-space:nowrap; }
.cost-line-value a { color:#8d92a2; }
.cost-line-detail { display:grid; grid-template-columns:minmax(0,1fr) minmax(120px,.4fr); gap:24px 40px; padding:18px 52px 28px; background:#fff; }
.cost-line-detail > div:not(.cost-child-lines) { display:grid; gap:5px; }
.cost-line-detail small { color:#a0a7bd; }
.cost-line-detail strong { font-weight:500; }
.cost-child-lines { grid-column:1/-1; border-top:1px solid #eeeef3; }
.cost-summary { width:min(520px,100%); margin:52px 0 0 auto; }
.cost-summary > div { display:grid; gap:8px; margin-bottom:18px; }
.cost-summary > div > strong { font-size:14px; }
.cost-summary > div > span { display:flex; justify-content:space-between; min-height:58px; padding:18px 20px; background:#f7f7f8; font-size:16px; }
.cost-summary small { font:inherit; }
@media (max-width:620px) { .cost-ledger-heading { grid-template-columns:1fr 82px; padding-inline:10px; }.cost-ledger-line > summary,.cost-ledger-line.flat { grid-template-columns:16px 26px minmax(0,1fr) 18px 82px; gap:7px; padding-inline:10px; }.cost-line-value { grid-column:5; }.cost-line-detail { grid-template-columns:1fr; padding-inline:34px; }.cost-child-lines { grid-column:1; } }
.application-body .entity-header-actions { display:flex; align-items:center; gap:8px; }
.application-body .detail-actions-menu { position:relative; z-index:12; }
.application-body .detail-actions-menu > summary { display:grid; place-items:center; width:36px; height:36px; color:#a5a9c1; border-radius:50%; cursor:pointer; font-size:22px; line-height:1; list-style:none; }
@@ -615,6 +648,23 @@ th { font-size:.7rem; letter-spacing:.06em; }
.application-body .unified-recipe-editor .percent-mode button.active { background:#eef2ff; box-shadow:inset 0 0 0 1px #d8e0ff; }
.application-body .unified-recipe-editor .editor-entity-link { color:#101a4a; text-decoration:none; }
.application-body .unified-recipe-editor .editor-entity-link + small { display:none; }
.calculator-ingredient-name,.pending-ingredient-name { display:inline-flex; align-items:center; gap:7px; }
.ingredient-attention-icon { display:inline-grid; flex:0 0 auto; place-items:center; width:18px; height:18px; color:#fff; background:#ef8d32; border-radius:50%; font:700 12px/1 var(--sans); cursor:help; }
.application-body .unified-recipe-editor .pending-ingredient-row td { background:#fff7f7; }
.application-body .unified-recipe-editor .pending-ingredient-row td:first-child { border-left:3px solid #e96368; }
.application-body .unified-recipe-editor .pending-ingredient-help { display:block; margin-top:3px; color:#c44c55; font-size:11px; }
.application-body .unified-recipe-editor .row-entity-combobox { position:relative; min-width:0; }
.application-body .unified-recipe-editor .row-entity-combobox > input { width:100%; min-height:42px; padding:8px 32px 8px 10px; color:#050841; background:#fff; border:0; border-bottom:2px solid transparent; border-radius:0; font:600 14px var(--sans); }
.application-body .unified-recipe-editor .row-entity-combobox > input:focus { background:#f1f5fe; border-bottom-color:#6379ff; outline:0; }
.application-body .unified-recipe-editor .row-entity-combobox.unmatched > input { background:#fff4f4; border-bottom-color:#f04f59; }
.application-body .unified-recipe-editor .row-entity-combobox > .ingredient-attention-icon { position:absolute; top:12px; right:8px; }
.application-body .unified-recipe-editor .row-entity-results { position:absolute; z-index:60; left:0; top:calc(100% - 1px); width:min(500px,calc(100vw - 48px)); max-height:470px; overflow-y:auto; background:#fff; border:1px solid #e2e3e8; box-shadow:0 8px 18px rgba(17,26,73,.22); }
.application-body .unified-recipe-editor .row-entity-results > button { display:flex; align-items:center; justify-content:space-between; gap:16px; width:100%; min-height:46px; padding:10px 20px; color:#050841; background:#fff; border:0; text-align:left; }
.application-body .unified-recipe-editor .row-entity-results > button:hover,.application-body .unified-recipe-editor .row-entity-results > button:focus { background:#f1f5fe; outline:0; }
.application-body .unified-recipe-editor .row-entity-results small { color:#9aa1b7; font-size:11px; font-weight:400; }
.application-body .unified-recipe-editor .row-create-actions { position:sticky; bottom:0; padding:7px 0; background:#fff; border-top:1px solid #e5e6eb; box-shadow:0 -4px 9px rgba(17,26,73,.06); }
.application-body .unified-recipe-editor .row-create-actions button { display:block; width:100%; padding:10px 20px; color:#4560ff; background:#fff; border:0; text-align:left; font:500 14px var(--sans); }
.application-body .unified-recipe-editor .row-create-actions button:hover,.application-body .unified-recipe-editor .row-create-actions button:focus { background:#f1f5fe; outline:0; }
.application-body .unified-recipe-editor .line-notes { width:100%; min-width:110px; padding:7px 5px; color:#687086; background:transparent; border:0; border-bottom:1px solid transparent; border-radius:0; font:italic 12px var(--sans); }
.application-body .unified-recipe-editor .line-notes:focus { border-bottom-color:#b9c5fb; outline:0; }
.application-body .unified-recipe-editor .remove-row { width:30px; height:30px; padding:0; color:#a7aec3; background:transparent; border:0; border-radius:50%; font-size:19px; line-height:1; }
@@ -662,6 +712,99 @@ th { font-size:.7rem; letter-spacing:.06em; }
outline:2px solid #b9c5fb;
outline-offset:2px;
}
.application-body .unified-recipe-editor .structure-add-actions .bulk-add-trigger {
margin-left:auto;
min-height:42px;
padding:8px 22px;
color:#050841;
background:#fff;
border:1px solid #050841;
border-radius:24px;
font-weight:600;
}
.application-body .unified-recipe-editor .add-line {
display:grid;
grid-template-columns:minmax(0,2fr) minmax(130px,1fr);
gap:0;
}
.application-body .unified-recipe-editor .add-line-notes {
width:100%;
min-height:58px;
padding:12px 14px;
color:#050841;
background:#fff;
border:1px solid #f3f3f3;
border-left:0;
border-radius:0;
font:italic 14px var(--sans);
}
.application-body .unified-recipe-editor .add-line-notes::placeholder { color:#a5a9c1; }
.bulk-ingredient-backdrop {
position:fixed;
z-index:1000;
inset:0;
display:grid;
place-items:center;
padding:24px;
background:rgba(5,8,31,.52);
}
.bulk-ingredient-dialog {
position:relative;
display:flex;
width:min(780px,100%);
max-height:calc(100vh - 48px);
padding:52px 68px 48px;
overflow:auto;
flex-direction:column;
color:#050841;
background:#fff;
border-radius:10px;
box-shadow:0 24px 70px rgba(5,8,31,.28);
}
.bulk-ingredient-dialog h2 { margin:0 0 24px; font-size:30px; font-weight:700; }
.bulk-ingredient-dialog > p:not(.bulk-dialog-error) { margin:0 0 8px; color:#8b8d99; font-size:15px; font-weight:500; }
.bulk-ingredient-dialog textarea {
width:100%;
min-height:330px;
padding:18px 14px;
resize:vertical;
color:#050841;
background:#fff;
border:1px solid #eeeef3;
border-bottom:1px solid #647df8;
border-radius:0;
font:15px/1.55 var(--sans);
}
.bulk-ingredient-dialog textarea::placeholder { color:#a5a9c1; opacity:1; }
.bulk-dialog-close {
position:absolute;
top:28px;
right:36px;
width:38px;
height:38px;
padding:0;
color:#050841;
background:transparent;
border:0;
cursor:pointer;
font-size:34px;
font-weight:300;
line-height:1;
}
.bulk-dialog-error { margin:7px 0 0; color:#ef3d4d; font-size:13px; font-weight:600; }
.bulk-entry-help { display:flex; margin:18px 0 34px; flex-direction:column; gap:12px; font-size:14px; }
.bulk-entry-help small { color:#a5a9c1; font-size:inherit; }
.bulk-ingredient-dialog footer { display:flex; align-items:center; justify-content:flex-end; gap:44px; }
.bulk-ingredient-dialog footer button { min-height:48px; padding:10px 24px; cursor:pointer; border:0; font-weight:700; }
.bulk-ingredient-dialog .bulk-cancel { color:#3d5df6; background:transparent; }
.bulk-ingredient-dialog .bulk-submit { min-width:250px; color:#fff; background:#647df8; border-radius:28px; }
.bulk-ingredient-dialog .bulk-submit:disabled { background:#aeb9f8; cursor:not-allowed; }
.bulk-prep-dialog { width:min(860px,100%); }
.bulk-prep-dialog textarea { min-height:390px; }
.application-body .unified-recipe-editor .method-editor li.prep-heading { min-height:70px; padding-block:18px; background:#fbfbfd; }
.application-body .unified-recipe-editor .method-editor li.prep-heading textarea { min-height:34px; font-weight:700; }
.application-body .unified-recipe-editor .method-editor li.prep-note { min-height:76px; background:#fafbfe; }
.application-body .unified-recipe-editor .method-editor li.prep-note textarea { min-height:44px; color:#727a91; font-style:italic; }
/* Recipe edit workspace refinements. */
.application-body .editable-entity-title { margin:2px 0 0; padding:0 0 4px; background:transparent; border:0; border-bottom:1px solid #f3f3f3; border-radius:0; color:#050841; font-size:30px; line-height:1.2; }
@@ -684,6 +827,15 @@ th { font-size:.7rem; letter-spacing:.06em; }
.application-body .unified-recipe-editor .table-wrap { border:0; }
.application-body .unified-recipe-editor .editor-items th { height:42px; padding:8px 10px; color:#757677; background:#fbfbfb; border-color:#f3f3f3; font-size:12px; font-weight:500; text-transform:none; }
.application-body .unified-recipe-editor .editor-items td { height:64px; padding:8px 10px; background:#fff; border:1px solid #f3f3f3; color:#050841; font-size:14px; }
.application-body .unified-recipe-editor .editor-items { width:100%; min-width:760px; table-layout:fixed; }
.application-body .unified-recipe-editor .editor-items th.base-column,.application-body .unified-recipe-editor .editor-items td.base-column { width:52px; min-width:0; padding-inline:6px; text-align:center; }
.application-body .unified-recipe-editor .editor-items th.percent-column,.application-body .unified-recipe-editor .editor-items td.percent-column { width:66px; min-width:0; padding-inline:6px; text-align:right; }
.application-body .unified-recipe-editor .editor-items th.quantity-column,.application-body .unified-recipe-editor .editor-items td.quantity-column { width:78px; min-width:0; text-align:right; }
.application-body .unified-recipe-editor .editor-items th.unit-column,.application-body .unified-recipe-editor .editor-items td.unit-column { width:112px; min-width:0; }
.application-body .unified-recipe-editor .editor-items th.entity-column,.application-body .unified-recipe-editor .editor-items td.entity-column { width:auto; min-width:0; }
.application-body .unified-recipe-editor .editor-items th.notes-column,.application-body .unified-recipe-editor .editor-items td.notes-column { width:22%; min-width:130px; }
.application-body .unified-recipe-editor .editor-items th.actions-column,.application-body .unified-recipe-editor .editor-items td.actions-column { width:64px; min-width:0; padding-inline:4px; }
.application-body .unified-recipe-editor .editor-items .percent-column input,.application-body .unified-recipe-editor .editor-items .quantity-column input { width:100%; min-width:0; padding-inline:5px; text-align:right; }
.application-body .unified-recipe-editor .editor-items input[type="number"],.application-body .unified-recipe-editor .editor-items select { min-height:45px; padding:8px 5px; background:#fff; border:0; border-radius:0; color:#050841; font-size:14px; }
.application-body .unified-recipe-editor .line-notes { min-height:45px; font-size:14px; }
.application-body .unified-recipe-editor .component-note-row { min-height:50px; margin:8px 0; background:#fff; border:1px solid #f3f3f3; }
@@ -707,6 +859,8 @@ th { font-size:.7rem; letter-spacing:.06em; }
.application-body .unified-recipe-editor .recipe-item-results button { display:flex; align-items:center; justify-content:space-between; width:100%; padding:11px 14px; color:#050841; background:#fff; border:0; border-bottom:1px solid #f3f3f3; text-align:left; }
.application-body .unified-recipe-editor .recipe-item-results button:hover,.application-body .unified-recipe-editor .recipe-item-results button:focus { background:#f1f5fe; outline:0; }
.application-body .unified-recipe-editor .recipe-item-results small { color:#a5a9c1; font-size:11px; }
.application-body .unified-recipe-editor .recipe-item-results .create-ingredient-result { position:sticky; bottom:0; color:#3d5df6; background:#fff; border-top:1px solid #dfe3ec; border-bottom:0; }
.application-body .unified-recipe-editor .recipe-item-results .create-ingredient-result small { color:#6876b5; }
.application-body .unified-recipe-editor .add-line > button:disabled { color:#a5a9c1; background:#f3f3f3; border-color:#ececec; cursor:not-allowed; }
.application-body .unified-recipe-editor .method-editor textarea { field-sizing:content; }
.application-body .unified-recipe-editor .dragging { opacity:.5; outline:2px solid #3d5df6; outline-offset:-2px; }
@@ -762,6 +916,11 @@ th { font-size:.7rem; letter-spacing:.06em; }
.application-body .unified-recipe-editor #recipe-tab-panel-slot > [hidden],.application-body .unified-recipe-editor #recipe-cover-slot[hidden],.application-body .unified-recipe-editor #recipe-additional-slot[hidden],.application-body .unified-recipe-editor #recipe-tab-panel-slot[hidden] { display:none !important; }
.application-body .unified-recipe-editor .method-editor .recipe-edit-tab-panel { width:100%; margin:0; padding:0; border:0; }
.application-body .unified-recipe-editor .method-editor .recipe-edit-tab-panel h2 { margin:0 0 28px; }
.application-body .recipe-edit-tab-panel { width:100%; min-height:680px; padding:48px 52px 80px; background:#fff; border-top:1px solid #eeeef3; }
.application-body .recipe-edit-tab-panel[hidden] { display:none !important; }
.application-body .recipe-edit-tab-panel .recipe-cost-ledger { width:100%; }
.application-body .recipe-edit-tab-panel .cost-ledger-lines { border-top:1px solid #eeeef3; }
@media (max-width:720px) { .application-body .recipe-edit-tab-panel { min-height:0; padding:30px 18px 60px; } }
.application-body .unified-recipe-editor .method-editor .recipe-equivalence-editor > div { background:#fff; }
.application-body .unified-recipe-editor .method-editor .recipe-equivalence-editor form { grid-template-columns:72px minmax(100px,1fr) auto 72px minmax(100px,1fr); }
.application-body .unified-recipe-editor .method-editor .recipe-equivalence-editor form input[name="notes"] { grid-column:1/-2; }
@@ -796,4 +955,15 @@ th { font-size:.7rem; letter-spacing:.06em; }
@media (max-width:620px) {
.application-body .inline-yield-editor input[name="yield_servings"] { width:100px; }
.application-body .inline-auto-yield { padding-top:0; }
.application-body .unified-recipe-editor .add-line { grid-template-columns:1fr; }
.application-body .unified-recipe-editor .add-line-notes { border-top:0; border-left:1px solid #f3f3f3; }
.application-body .unified-recipe-editor .structure-add-actions { flex-wrap:wrap; gap:12px 20px; }
.application-body .unified-recipe-editor .structure-add-actions .bulk-add-trigger { width:100%; margin:6px 0 0; justify-content:center; }
.bulk-ingredient-backdrop { padding:0; }
.bulk-ingredient-dialog { width:100%; max-height:100vh; min-height:100vh; padding:52px 22px 28px; border-radius:0; }
.bulk-ingredient-dialog h2 { font-size:25px; }
.bulk-ingredient-dialog textarea { min-height:280px; }
.bulk-dialog-close { top:16px; right:18px; }
.bulk-ingredient-dialog footer { gap:12px; }
.bulk-ingredient-dialog .bulk-submit { min-width:0; flex:1; }
}