585 lines
37 KiB
TypeScript
585 lines
37 KiB
TypeScript
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 { CostLine, CostResult } from "../lib/costing";
|
||
import NutritionPanel from "./NutritionPanel";
|
||
import { number, roundForDisplay } from "../lib/format";
|
||
import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../lib/icons";
|
||
|
||
type Props = {
|
||
components: CalculatorComponent[];
|
||
units: Record<string, Unit>;
|
||
basisUnitId: string;
|
||
yieldQuantity: number;
|
||
yieldUnitId: string;
|
||
mode: "amount" | "percentage" | "hybrid";
|
||
nutrition: NutritionResult;
|
||
cost: CostResult;
|
||
servings?: number;
|
||
showDerived?: boolean;
|
||
showPercentControls?: boolean;
|
||
yieldConversions?: CalculatorItem["measureConversions"];
|
||
shelfLife?: string | { duration?: { quantity?: number; unit_id?: string }; storage_condition?: string };
|
||
};
|
||
|
||
function itemUnits(item: CalculatorItem, units: Record<string, Unit>) {
|
||
const dimensions = new Set([units[item.amount.unit_id]?.dimension]);
|
||
for (const conversion of item.measureConversions ?? []) {
|
||
dimensions.add(units[conversion.from.unit_id]?.dimension);
|
||
dimensions.add(units[conversion.to.unit_id]?.dimension);
|
||
}
|
||
return Object.values(units).filter((unit) => dimensions.has(unit.dimension));
|
||
}
|
||
|
||
function convertItem(quantity: number, fromUnitId: string, toUnitId: string, item: CalculatorItem, units: Record<string, Unit>) {
|
||
if (units[fromUnitId]?.dimension === units[toUnitId]?.dimension) return convert(quantity, fromUnitId, toUnitId, units);
|
||
for (const equivalency of item.measureConversions ?? []) {
|
||
const fromDimension = units[equivalency.from.unit_id]?.dimension;
|
||
const toDimension = units[equivalency.to.unit_id]?.dimension;
|
||
if (units[fromUnitId]?.dimension === fromDimension && units[toUnitId]?.dimension === toDimension) {
|
||
const atBridge = convert(quantity, fromUnitId, equivalency.from.unit_id, units);
|
||
return convert(atBridge * equivalency.to.quantity / equivalency.from.quantity, equivalency.to.unit_id, toUnitId, units);
|
||
}
|
||
if (units[fromUnitId]?.dimension === toDimension && units[toUnitId]?.dimension === fromDimension) {
|
||
const atBridge = convert(quantity, fromUnitId, equivalency.to.unit_id, units);
|
||
return convert(atBridge * equivalency.from.quantity / equivalency.to.quantity, equivalency.from.unit_id, toUnitId, units);
|
||
}
|
||
}
|
||
throw new Error(`No reviewed equivalency from ${fromUnitId} to ${toUnitId}`);
|
||
}
|
||
|
||
const SCALE_OPTIONS = [
|
||
{ value: 0.5, label: "1/2x" },
|
||
{ value: 1, label: "1x" },
|
||
{ value: 2, label: "2x" },
|
||
{ value: 3, label: "3x" },
|
||
{ value: 4, label: "4x" },
|
||
{ value: 5, label: "5x" },
|
||
];
|
||
|
||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [], shelfLife }: Props) {
|
||
const [factor, setFactor] = useState(1);
|
||
const [isCustomScale, setIsCustomScale] = useState(false);
|
||
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
|
||
const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
|
||
const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId);
|
||
const [lineUnits, setLineUnits] = useState<Record<string, string>>({});
|
||
const [showScaleTooltip, setShowScaleTooltip] = useState(false);
|
||
const [scaleTooltipTimer, setScaleTooltipTimer] = useState<any>(null);
|
||
const [activeAttentionId, setActiveAttentionId] = useState<string | null>(null);
|
||
const validFactor = Number.isFinite(factor) && factor >= 0 ? factor : 0;
|
||
|
||
const matchedScaleOption = SCALE_OPTIONS.find((opt) => Math.abs(opt.value - factor) < 0.001);
|
||
const showCustomInput = isCustomScale || !matchedScaleOption;
|
||
|
||
const handleScaleSelectChange = (e: Event) => {
|
||
const val = (e.currentTarget as HTMLSelectElement).value;
|
||
if (val === "custom") {
|
||
setIsCustomScale(true);
|
||
} else {
|
||
setIsCustomScale(false);
|
||
setFactor(Number(val));
|
||
}
|
||
};
|
||
|
||
const handleScaleTooltipEnter = () => {
|
||
if (scaleTooltipTimer) clearTimeout(scaleTooltipTimer);
|
||
setShowScaleTooltip(true);
|
||
};
|
||
|
||
const handleScaleTooltipLeave = () => {
|
||
const timer = setTimeout(() => {
|
||
setShowScaleTooltip(false);
|
||
}, 150);
|
||
setScaleTooltipTimer(timer);
|
||
};
|
||
|
||
const handleScaleTooltipToggle = (e: MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setShowScaleTooltip((prev) => !prev);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as HTMLElement | null;
|
||
if (!target?.closest(".helper-tooltip-wrap")) {
|
||
setShowScaleTooltip(false);
|
||
}
|
||
if (!target?.closest(".ingredient-attention-wrapper")) {
|
||
setActiveAttentionId(null);
|
||
}
|
||
};
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
setShowScaleTooltip(false);
|
||
setActiveAttentionId(null);
|
||
}
|
||
};
|
||
document.addEventListener("click", handleClickOutside);
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
document.removeEventListener("click", handleClickOutside);
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, []);
|
||
|
||
const shelfLifeText = useMemo(() => {
|
||
if (!shelfLife) return null;
|
||
if (typeof shelfLife === "string") return shelfLife.trim() || null;
|
||
const q = shelfLife.duration?.quantity;
|
||
const u = shelfLife.duration?.unit_id;
|
||
if (q != null && u) {
|
||
const unitLabel = q === 1 ? u : `${u}s`;
|
||
return `${q} ${unitLabel}`;
|
||
}
|
||
return null;
|
||
}, [shelfLife]);
|
||
|
||
useEffect(() => { window.dispatchEvent(new CustomEvent("recipe-scale-change", { detail:validFactor })); }, [validFactor]);
|
||
const itemWeight=(item:CalculatorItem)=>{try{return convertItem(item.amount.quantity,item.amount.unit_id,"gram",item,units);}catch{return undefined;}};
|
||
const total = useMemo(
|
||
() => components.flatMap((component) => component.items).reduce((sum, item) => {
|
||
try { return sum + convertItem(item.amount.quantity * validFactor, item.amount.unit_id, basisUnitId, item, units); }
|
||
catch { return sum; }
|
||
}, 0),
|
||
[components, validFactor, basisUnitId, units],
|
||
);
|
||
const unconvertedCount=useMemo(()=>components.flatMap((component)=>component.items).filter((item)=>{try{convertItem(item.amount.quantity,item.amount.unit_id,basisUnitId,item,units);return false;}catch{return true;}}).length,[components,basisUnitId,units]);
|
||
const percentageBase=useMemo(()=>{if(!calculatePercent)return 0;if(percentMode==="standard")return total;return components.flatMap(c=>c.items).filter(item=>item.basisMember).reduce((sum,item)=>{const w=itemWeight(item);return w!=null?sum+w*validFactor:sum;},0);},[calculatePercent,percentMode,total,components,units,validFactor]);
|
||
const baseItems=components.flatMap(component=>component.items).filter(item=>item.basisMember);
|
||
const basisUnit = units[basisUnitId];
|
||
const yieldItem = { id:"yield", label:"Yield", amount:{quantity:yieldQuantity,unit_id:yieldUnitId}, measureConversions:yieldConversions } as CalculatorItem;
|
||
const yieldUnits = useMemo(() => itemUnits(yieldItem,units), [yieldItem, units]);
|
||
const scaledYield = useMemo(() => {
|
||
try { return convertItem(yieldQuantity * validFactor, yieldUnitId, yieldDisplayUnitId, yieldItem, units); }
|
||
catch { return yieldQuantity * validFactor; }
|
||
}, [yieldQuantity, validFactor, yieldUnitId, yieldDisplayUnitId, yieldItem, units]);
|
||
|
||
const changeYield = (nextYield: number) => {
|
||
if (!Number.isFinite(nextYield) || nextYield <= 0 || yieldQuantity <= 0) return;
|
||
try {
|
||
const canonicalNextYield = convertItem(nextYield, yieldDisplayUnitId, yieldUnitId, yieldItem, units);
|
||
setFactor(canonicalNextYield / yieldQuantity);
|
||
} catch {
|
||
setFactor(nextYield / yieldQuantity);
|
||
}
|
||
};
|
||
|
||
const currentScaleLabel = !showCustomInput && matchedScaleOption ? matchedScaleOption.label : "Custom";
|
||
const yieldUnitSymbol = yieldUnits.find((u) => u.id === yieldDisplayUnitId)?.symbol ?? yieldDisplayUnitId;
|
||
const yieldQtyString = String(roundForDisplay(scaledYield));
|
||
const customFactorString = String(roundForDisplay(factor));
|
||
|
||
return (
|
||
<section class="calculator" aria-label="Recipe scaling calculator">
|
||
<div class="calculator-heading" data-testid="metaItem">
|
||
<div class="basis-input batch-multiplier-field">
|
||
<span class="scale-label">Batch Size:</span>
|
||
<span class="recipe-scale-control batch-size-control">
|
||
<select
|
||
aria-label="Batch size multiplier"
|
||
class="fmt-scale-select"
|
||
data-testid="ScaleSelectorBase_scale_select"
|
||
style={{ width: `${currentScaleLabel.length + 2.4}ch` }}
|
||
value={!showCustomInput && matchedScaleOption ? String(matchedScaleOption.value) : "custom"}
|
||
onChange={handleScaleSelectChange}
|
||
>
|
||
{SCALE_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={String(opt.value)}>{opt.label}</option>
|
||
))}
|
||
<option value="custom">Custom</option>
|
||
</select>
|
||
{showCustomInput && (
|
||
<span class="custom-scale-wrap">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
aria-label="Custom batch multiplier"
|
||
class="custom-scale-input"
|
||
style={{ width: `${Math.max(2, customFactorString.length + 0.8)}ch` }}
|
||
value={customFactorString}
|
||
onFocus={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||
onClick={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||
onInput={(event) => {
|
||
const val = Number((event.currentTarget as HTMLInputElement).value);
|
||
if (!isNaN(val) && val > 0) setFactor(val);
|
||
}}
|
||
autoFocus
|
||
/>
|
||
<span class="batch-unit-suffix">x</span>
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div class="basis-input finished-yield-field">
|
||
<span class="scale-label">Yield:</span>
|
||
<span class="recipe-scale-control quantity-control">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
aria-label="Finished yield quantity"
|
||
style={{ width: `${Math.max(2, yieldQtyString.length + 0.5)}ch` }}
|
||
value={yieldQtyString}
|
||
onFocus={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||
onClick={(event) => (event.currentTarget as HTMLInputElement).select()}
|
||
onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))}
|
||
/>
|
||
<select
|
||
aria-label="Finished yield unit"
|
||
value={yieldDisplayUnitId}
|
||
style={{ width: `${yieldUnitSymbol.length + 0.8}ch` }}
|
||
onChange={(event) => setYieldDisplayUnitId((event.currentTarget as HTMLSelectElement).value)}
|
||
>
|
||
{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}
|
||
</select>
|
||
</span>
|
||
<span
|
||
class="helper-tooltip-wrap"
|
||
data-testid="HelperTextTooltipBase"
|
||
onMouseEnter={handleScaleTooltipEnter}
|
||
onMouseLeave={handleScaleTooltipLeave}
|
||
>
|
||
<button
|
||
class="HelperTooltipBase_help_icon"
|
||
type="button"
|
||
data-testid="HelperTooltipBase_help_icon"
|
||
aria-label="Yield calculation help"
|
||
aria-expanded={showScaleTooltip}
|
||
onClick={handleScaleTooltipToggle}
|
||
>
|
||
<svg class="helper-tooltip-svg" viewBox="-1 0 20 18" width="18" height="18" fill="none" data-testid="TooltipWithIcon_IconDefault">
|
||
<path d="M17.376 17.8138C17.3808 17.7971 17.3855 17.7806 17.3862 17.7631C17.3865 17.7589 17.3887 17.7554 17.3887 17.7512C17.3887 17.7377 17.3833 17.7262 17.3813 17.7137C17.3793 17.7012 17.3813 17.689 17.3773 17.6768L14.2885 7.8178C14.2873 7.8138 14.284 7.81131 14.283 7.80731C14.2768 7.79059 14.2673 7.77611 14.2576 7.76114C14.2496 7.74816 14.2426 7.73493 14.2324 7.72395C14.2219 7.71272 14.2092 7.70448 14.1967 7.6955C14.1832 7.68526 14.1707 7.67478 14.1557 7.66779C14.143 7.6618 14.1293 7.65955 14.1153 7.65581C14.0976 7.65082 14.0801 7.64583 14.0614 7.64508C14.0574 7.64483 14.0542 7.64283 14.0502 7.64283H11.9598V5.60864C14.2521 4.65244 16.0574 2.69088 16.8052 0.324972C16.8092 0.312492 16.8072 0.300262 16.8092 0.288032C16.8114 0.275053 16.8169 0.263322 16.8169 0.249844C16.8169 0.245851 16.8147 0.242356 16.8144 0.238363C16.8137 0.220392 16.8089 0.204168 16.8042 0.187196C16.8002 0.17247 16.798 0.157744 16.7915 0.144266C16.7847 0.129789 16.7745 0.118058 16.765 0.10483C16.7555 0.0921004 16.7475 0.0788719 16.7358 0.0678898C16.7248 0.0576564 16.7111 0.0509174 16.6986 0.0426808C16.6836 0.0331962 16.6699 0.0237114 16.6529 0.0174715C16.6492 0.015974 16.6467 0.0129789 16.6427 0.0117309C16.6297 0.00748782 16.617 0.00948452 16.6043 0.00748777C16.5918 0.00549101 16.5806 0 16.5676 0H0.820666C0.806939 0 0.795208 0.00549111 0.782478 0.00773746C0.769999 0.00973422 0.757519 0.00773741 0.745289 0.0117309C0.741545 0.0129789 0.739049 0.0159741 0.735305 0.0172221C0.718333 0.0234619 0.704106 0.0331962 0.68913 0.0426808C0.676401 0.0509174 0.663172 0.0574067 0.65244 0.0676401C0.640709 0.0786222 0.632223 0.0921005 0.622738 0.105079C0.613253 0.117809 0.60327 0.129789 0.596531 0.144016C0.590041 0.157494 0.587795 0.17222 0.583801 0.186946C0.579059 0.203919 0.574317 0.220392 0.573318 0.238113C0.573069 0.242107 0.571072 0.245351 0.571072 0.249345C0.571072 0.262573 0.576563 0.274304 0.57856 0.287033C0.580557 0.299763 0.57856 0.312243 0.582803 0.324722C1.34432 2.72507 3.10645 4.63946 5.42818 5.60589V7.64233H3.33882C3.33458 7.64233 3.33134 7.64458 3.32709 7.64458C3.30887 7.64533 3.29215 7.65032 3.27468 7.65506C3.26045 7.65905 3.24622 7.6613 3.23325 7.66729C3.21802 7.67453 3.20554 7.68501 3.19206 7.695C3.17983 7.70423 3.1671 7.71197 3.15662 7.7232C3.14614 7.73418 3.1394 7.74791 3.13091 7.76089C3.12168 7.77586 3.11219 7.78984 3.10595 7.80656C3.10445 7.81031 3.10146 7.81305 3.10021 7.81705L0.0114815 17.676C0.00748795 17.6885 0.00948452 17.7007 0.00748777 17.713C0.00549101 17.7257 0 17.7372 0 17.7504C0 17.7547 0.00224641 17.7581 0.002496 17.7624C0.00324478 17.7801 0.00823653 17.7963 0.0127292 17.8133C0.0167227 17.828 0.0192187 17.8428 0.0257081 17.8565C0.0324472 17.871 0.0424311 17.8824 0.0519157 17.8954C0.0614003 17.9084 0.0696368 17.9216 0.0816173 17.9326C0.0923498 17.9426 0.105578 17.9491 0.118308 17.9573C0.133283 17.9668 0.14776 17.9765 0.164732 17.983C0.168476 17.9843 0.171222 17.9875 0.175215 17.9885C0.199925 17.9963 0.224884 17.9998 0.249345 18H17.1387C17.1634 18 17.1883 17.9963 17.2133 17.9885C17.2173 17.9873 17.2198 17.9843 17.2235 17.983C17.2407 17.9768 17.2552 17.9671 17.2704 17.9573C17.2829 17.9493 17.2961 17.9426 17.3069 17.9326C17.3189 17.9216 17.3273 17.9081 17.3368 17.8952C17.3461 17.8822 17.3563 17.8707 17.3628 17.8565C17.3698 17.8435 17.3723 17.8285 17.376 17.8138ZM1.17185 0.498939H16.2166C15.4372 2.63023 13.7414 4.37739 11.6196 5.21103C11.4731 5.26819 11.3136 5.32435 11.1446 5.37801C9.57419 5.88544 7.82004 5.88644 6.2441 5.37751C6.07987 5.32585 5.92487 5.27094 5.76937 5.20879C3.61587 4.36191 1.95807 2.65568 1.17185 0.498939ZM5.92737 5.79433C5.98278 5.8133 6.03594 5.83527 6.09285 5.85299C6.92799 6.1228 7.80357 6.25933 8.69537 6.25933C9.58742 6.25933 10.463 6.12255 11.2969 5.85324C11.3531 5.83552 11.4065 5.8168 11.4609 5.79833V7.64208H5.92737V5.79433Z" fill="currentColor"/>
|
||
<path d="M12.3787 12.9467C12.3787 14.9766 10.7271 16.6282 8.69714 16.6282C6.66719 16.6282 5.01562 14.9766 5.01562 12.9467C5.01562 10.9167 6.66719 9.26514 8.69714 9.26514C10.7271 9.26514 12.3787 10.9167 12.3787 12.9467Z" fill="white"/>
|
||
<path d="M8.69464 13.1962C8.75479 13.1962 8.81519 13.1745 8.86337 13.1303L10.6312 11.5079C10.7328 11.4148 10.7398 11.2568 10.6465 11.1555C10.5534 11.0537 10.3949 11.0469 10.294 11.1403L8.52616 12.7626C8.42458 12.8557 8.41784 13.0137 8.51094 13.1151C8.55986 13.1687 8.62725 13.1962 8.69464 13.1962Z" fill="currentColor"/>
|
||
<path d="M8.81381 6.49281C13.1681 6.49281 16.6979 3.58588 16.6979 0H0.929688C0.929688 3.58588 4.45953 6.49281 8.81381 6.49281Z" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
{showScaleTooltip && (
|
||
<div class="helper-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||
<div class="tooltip-section">
|
||
<b>Ways to scale your recipe:</b>
|
||
<ul>
|
||
<li>Click on an ingredient's quantity and update it</li>
|
||
<li>Click on the recipe's <b>Total Yield</b> and update it</li>
|
||
<li>Change the <b>Batch Size</b> of the recipe</li>
|
||
</ul>
|
||
</div>
|
||
<div class="tooltip-section">
|
||
<b>How to convert units of measure:</b>
|
||
<ul>
|
||
<li>Click on an ingredient's unit of measure & change to the desired unit - the quantity will adjust automatically</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{shelfLifeText && (
|
||
<div class="basis-input shelf-life-field">
|
||
<span class="scale-label">Shelf Life:</span>
|
||
<span class="shelf-life-value">{shelfLifeText}</span>
|
||
</div>
|
||
)}
|
||
{showPercentControls && (
|
||
<div class="calculator-percent-controls">
|
||
{calculatePercent && (
|
||
<span class="percent-mode">
|
||
<button type="button" class={percentMode === "standard" ? "active" : ""} onClick={() => setPercentMode("standard")}>Standard %</button>
|
||
<button type="button" class={percentMode === "bakers" ? "active" : ""} onClick={() => setPercentMode("bakers")}>Baker's %</button>
|
||
</span>
|
||
)}
|
||
<label class="calculate-toggle">
|
||
<span>Calculate %</span>
|
||
<input type="checkbox" checked={calculatePercent} onChange={(event) => setCalculatePercent((event.currentTarget as HTMLInputElement).checked)} />
|
||
<i></i>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return <p><span><input aria-label={`${item.label} base quantity`} type="text" inputMode="decimal" style={{ width: `${Math.max(2, String(roundForDisplay(displayQuantity)).length + 0.5)}ch` }} value={roundForDisplay(displayQuantity)} onFocus={(event) => (event.currentTarget as HTMLInputElement).select()} onClick={(event) => (event.currentTarget as HTMLInputElement).select()} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
|
||
{components.map((component) => (
|
||
<div class="formula-component" key={component.id}>
|
||
{Boolean(component.name?.trim()) && <h3 class="formula-component-heading">{component.name}</h3>}
|
||
{(component.notes ?? []).filter(Boolean).map((note, noteIdx) => (
|
||
<p key={noteIdx} class="formula-component-note">{note}</p>
|
||
))}
|
||
<div class="table-wrap">
|
||
<table class="recipe-ingredients">
|
||
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
|
||
<tbody>
|
||
{component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => {
|
||
const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id;
|
||
const compatibleUnits = itemUnits(item, units);
|
||
const displayQuantity = convertItem(item.amount.quantity * validFactor, item.amount.unit_id, displayUnitId, item, units);
|
||
const changeLineQuantity = (value: number) => {
|
||
const canonicalQuantity = convertItem(value, displayUnitId, item.amount.unit_id, item, units);
|
||
setFactor(item.amount.quantity > 0 ? canonicalQuantity / item.amount.quantity : 1);
|
||
};
|
||
return (
|
||
<tr key={item.id} class={item.basisMember ? "basis-row" : ""}>
|
||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="text" inputMode="decimal" style={{ width: `${Math.max(2, String(roundForDisplay(displayQuantity)).length + 0.5)}ch` }} value={roundForDisplay(displayQuantity)} onFocus={(event) => (event.currentTarget as HTMLInputElement).select()} onClick={(event) => (event.currentTarget as HTMLInputElement).select()} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} style={{width:`${(compatibleUnits.find((unit) => unit.id === displayUnitId)?.symbol ?? displayUnitId).length + 0.75}ch`}} onChange={(event) => { const next = event.currentTarget.value; try { convertItem(item.amount.quantity * validFactor, item.amount.unit_id, next, item, units); } catch { event.currentTarget.value = displayUnitId; return; } setLineUnits((current) => ({ ...current, [item.id]: next })); }}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||
<td>
|
||
<span class="calculator-ingredient-name">
|
||
{item.href ? <a href={item.href}>{item.label}</a> : item.label}
|
||
{item.attention && (
|
||
<span
|
||
class="ingredient-attention-wrapper"
|
||
onMouseEnter={() => setActiveAttentionId(item.id)}
|
||
onMouseLeave={() => setActiveAttentionId((prev) => (prev === item.id ? null : prev))}
|
||
>
|
||
<button
|
||
type="button"
|
||
class="ingredient-attention-icon"
|
||
aria-label={item.attentionMessage ?? "Needs attention"}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setActiveAttentionId((prev) => (prev === item.id ? null : item.id));
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
|
||
<path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
|
||
</svg>
|
||
</button>
|
||
{activeAttentionId === item.id && (
|
||
<div class="ingredient-attention-popper fmt-tooltip-card" role="tooltip">
|
||
<p>{item.attentionMessage ?? "No conversion equivalency available for this unit."}</p>
|
||
</div>
|
||
)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{item.optional && <span class="muted"> optional</span>}
|
||
{item.notes && <small>{item.notes}</small>}
|
||
</td>
|
||
{calculatePercent&&<td class="recipe-percent-cell">{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div class="formula-total"><span>Scaled ingredient total</span><strong>{number(total)} {basisUnit?.symbol ?? basisUnitId}</strong></div>
|
||
{unconvertedCount>0&&<p class="formula-conversion-warning">{unconvertedCount} ingredient {unconvertedCount===1?"amount is":"amounts are"} excluded from this total because no weight or volume equivalency is available.</p>}
|
||
{showDerived && <DerivedValues nutrition={nutrition} cost={cost} servings={servings} factor={validFactor} />}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function DerivedValues({ nutrition, cost, servings, factor }: { nutrition: NutritionResult; cost: CostResult; servings?: number; factor: number }) {
|
||
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: cost.currency, minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||
return <div class="derived-grid">
|
||
<div id="nutrition"><NutritionPanel nutrition={nutrition} servings={servings} factor={factor} /></div>
|
||
<section id="costing" class="derived-card" aria-labelledby="scaled-cost-heading">
|
||
<div class="derived-title">
|
||
<div>
|
||
<p class="eyebrow">Derived estimate</p>
|
||
<h3 id="scaled-cost-heading">Recipe cost</h3>
|
||
</div>
|
||
<div class="derived-cost-status">
|
||
<strong>{Math.round(cost.completeness * 100)}% priced</strong>
|
||
{cost.completeness < 1 && (
|
||
<span class="cost-help-wrap">
|
||
<button class="cost-help-btn" type="button" aria-label="Cost calculation details">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||
<path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/>
|
||
</svg>
|
||
</button>
|
||
<span class="cost-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||
<b>There is not enough information to calculate cost. Please verify the below:</b>
|
||
<ul>
|
||
<li>This recipe and all its sub-recipes must include Total Yield</li>
|
||
<li>All ingredients in this recipe and its sub-recipes must be defined</li>
|
||
<li>All ingredients in this recipe and its sub-recipes must have a purchase cost</li>
|
||
<li>All ingredients in this recipe and its sub-recipes must have the needed unit conversion to convert from the unit in the recipe to the purchase unit</li>
|
||
<li>If this recipe exists in multiple concepts or locations, there is a cost at each concept/location</li>
|
||
</ul>
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</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)}</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>}
|
||
</section>
|
||
</div>;
|
||
}
|
||
|
||
export function LiveDerivedValues({ nutrition, cost, servings }: { nutrition: NutritionResult; cost: CostResult; servings?: number }) {
|
||
const [factor,setFactor]=useState(1);
|
||
useEffect(()=>{const update=(event:Event)=>setFactor((event as CustomEvent<number>).detail);window.addEventListener("recipe-scale-change",update);return()=>window.removeEventListener("recipe-scale-change",update);},[]);
|
||
return <DerivedValues nutrition={nutrition} cost={cost} servings={servings} factor={factor}/>;
|
||
}
|
||
|
||
function useLiveFactor() {
|
||
const [factor,setFactor]=useState(1);
|
||
useEffect(()=>{const update=(event:Event)=>setFactor((event as CustomEvent<number>).detail);window.addEventListener("recipe-scale-change",update);return()=>window.removeEventListener("recipe-scale-change",update);},[]);
|
||
return factor;
|
||
}
|
||
|
||
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 editHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/?edit=1#costs` : `/app/recipes/${line.subjectId}/?edit=1#costing`;
|
||
const viewHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/#costs` : `/app/recipes/${line.subjectId}/#costing`;
|
||
const hasChildren = Boolean(line.purchase || line.children?.length);
|
||
const costValue = line.cost != null ? money.format(line.cost * factor) : "—";
|
||
|
||
const handleSummaryClick = (e: MouseEvent) => {
|
||
const target = e.target as HTMLElement | null;
|
||
if (target?.closest("a") || target?.closest("button")) {
|
||
return;
|
||
}
|
||
if (!hasChildren) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<details class={`cost-ledger-line ${hasChildren ? "has-children" : "flat"}`} open={expanded}>
|
||
<summary onClick={handleSummaryClick}>
|
||
<span class="cost-toggle-marker" aria-hidden="true">
|
||
{hasChildren && (
|
||
<svg viewBox="0 0 24 24" width="14" height="14" class="chevron-icon">
|
||
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||
</svg>
|
||
)}
|
||
</span>
|
||
<span class={`cost-subject-icon ${line.kind}`} aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||
<path
|
||
fill="currentColor"
|
||
d={TYPE_ICONS[line.kind as "recipe" | "ingredient"] ?? TYPE_ICONS.ingredient}
|
||
style={TYPE_ICON_TRANSFORMS[line.kind as "recipe" | "ingredient"] ? { transform: TYPE_ICON_TRANSFORMS[line.kind as "recipe" | "ingredient"] } : undefined}
|
||
/>
|
||
</svg>
|
||
</span>
|
||
<a href={editable ? editHref : viewHref} class="cost-subject-name" onClick={(e) => e.stopPropagation()}>{line.name}</a>
|
||
<span class="cost-attention-slot">
|
||
{line.completeness < 1 && (
|
||
<span class="cost-line-help-wrap">
|
||
<button
|
||
type="button"
|
||
class="cost-attention-badge"
|
||
aria-label="Cost information is incomplete"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||
<path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/>
|
||
</svg>
|
||
</button>
|
||
<span class="cost-line-tooltip fmt-tooltip-card" role="tooltip">
|
||
{line.purchase ? "Cost information is incomplete" : "This ingredient is not yet set with a purchase cost and unit"}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span class="cost-line-value">
|
||
{editable && line.kind === "ingredient" ? (
|
||
<a href={editHref} class="cost-editable-link" title={line.cost != null ? "Edit purchase cost" : "Add purchase cost"} onClick={(e) => e.stopPropagation()}>
|
||
<span>{line.cost != null ? costValue : "Add cost"}</span>
|
||
<span class="cost-edit-icon" aria-hidden="true">✎</span>
|
||
</a>
|
||
) : (
|
||
<span>{costValue}</span>
|
||
)}
|
||
</span>
|
||
</summary>
|
||
{hasChildren && (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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});
|
||
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 class="cost-ledger-header">
|
||
<div class="cost-title-row">
|
||
<h2>Recipe Cost</h2>
|
||
{cost.completeness < 1 && (
|
||
<span class="cost-help-wrap">
|
||
<button class="cost-help-btn" type="button" aria-label="Cost calculation help">
|
||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||
<path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/>
|
||
</svg>
|
||
</button>
|
||
<span class="cost-tooltip-popper fmt-tooltip-card" role="tooltip">
|
||
<b>There is not enough information to calculate cost. Please verify the below:</b>
|
||
<ul>
|
||
<li>This recipe and all its sub-recipes must include Total Yield</li>
|
||
<li>All ingredients in this recipe and its sub-recipes must be defined</li>
|
||
<li>All ingredients in this recipe and its sub-recipes must have a purchase cost</li>
|
||
<li>All ingredients in this recipe and its sub-recipes must have the needed unit conversion to convert from the unit in the recipe to the purchase unit</li>
|
||
<li>If this recipe exists in multiple concepts or locations, there is a cost at each concept/location</li>
|
||
</ul>
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p>{editable?"Update an ingredient’s 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 class="head-subject">
|
||
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 class="head-cost">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 }) {
|
||
return <NutritionPanel nutrition={nutrition} servings={servings} factor={useLiveFactor()} ingredients={ingredients} editable={editable} saveVersion={saveVersion}/>;
|
||
}
|