recipe-coster/coster.html

321 lines
16 KiB
HTML
Raw Permalink Normal View History

2026-08-21 14:22:19 +02:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recipe Coster — Rony Thomas, Kitchen Ops</title>
<meta property="og:type" content="website">
<meta property="og:title" content="Recipe Coster — Rony Thomas, Kitchen Ops">
<meta property="og:description" content="A real kitchen recipe coster: yield run-throughs, cost per portion, sell price, food-cost % — the math behind a calm, profitable dinner line.">
<meta property="og:url" content="https://rony-thomas.4ort.net/coster.html">
<meta name="twitter:card" content="summary">
<meta name="description" content="A real kitchen recipe coster: yield run-throughs, cost per portion, sell price, food-cost % — the math behind a calm, profitable dinner line.">
<style>
:root{
--paper:#f4efe4; /* ticket paper */
--ink:#1c1a16; /* near-black */
--rev:#d9422a; /* sales-sticker red */
--accent:#247a4a; /* collard green */
--line:#8a8374;
--brown:#6e5a35;
}
*{box-sizing:border-box}
body{
background:var(--paper);
color:var(--ink);
font-family:"Courier New",ui-monospace,Menlo,monospace;
margin:0; line-height:1.45;
/* retro print noise */
background-image:
repeating-linear-gradient(0deg, rgba(28,26,22,.02) 0 1px, transparent 1px 3px);
}
.wrap{max-width:860px;margin:0 auto;padding:24px}
header.ticket{
border:3px solid var(--ink);
background:var(--paper);
padding:18px 22px; margin-bottom:6px;
box-shadow:6px 6px 0 rgba(28,26,22,.12);
}
header.ticket h1{
margin:0;font-size:1.9rem;letter-spacing:-.5px;font-weight:700;
border-bottom:3px double var(--ink);display:inline-block;
}
.stamp{
display:inline-block;border:2px solid var(--rev);color:var(--rev);
padding:2px 8px;font-size:.8rem;letter-spacing:2px;font-weight:700;
transform:rotate(-2deg);text-transform:uppercase;
}
.sub{color:var(--brown);font-size:.85rem;margin-top:8px}
nav.site{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 22px}
nav.site a{
border:1.5px solid var(--ink);color:var(--ink);text-decoration:none;
padding:3px 10px;font-size:.8rem;background:var(--paper);
}
nav.site a:hover{background:var(--ink);color:var(--paper)}
fieldset{border:2px solid var(--ink);padding:14px;margin:14px 0}
legend{font-weight:700;padding:0 8px;text-transform:uppercase;letter-spacing:1px;font-size:.85rem}
label{display:block;font-size:.8rem;margin:10px 0 3px;text-transform:uppercase;letter-spacing:.5px;color:var(--brown)}
input,select{
width:100%;padding:8px 10px;font-family:inherit;font-size:1rem;
border:1.5px solid var(--ink);background:#fbf8f0;color:var(--ink);
}
input:focus{outline:2px solid var(--accent)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
button{
background:var(--ink);color:var(--paper);border:none;cursor:pointer;
font-family:inherit;font-size:1rem;font-weight:700;padding:12px 18px;
letter-spacing:1px;text-transform:uppercase;
}
button:hover{background:var(--rev)}
.row{display:flex;gap:10px;align-items:center;margin-bottom:8px}
.row select{width:120px;flex:0 0 auto}
.row input.g{flex:1 1 100px}
.row input.p{flex:0 0 90px}
.row .remove{border:1px solid var(--rev);color:var(--rev);background:none;padding:4px 8px;cursor:pointer}
#addIng{
margin-top:6px;border:1.5px dashed var(--line);color:var(--brown);background:none;
padding:8px 12px;font-family:inherit;font-size:.85rem;cursor:pointer;
}
#addIng:hover{border:1.5px solid var(--ink);color:var(--ink)}
table{width:100%;border-collapse:collapse;margin-top:10px;font-size:.9rem}
th,td{border:1.5px solid var(--ink);padding:6px 8px;text-align:right}
th{background:var(--ink);color:var(--paper);font-size:.8rem;text-transform:uppercase}
td:nth-child(1),th:nth-child(1){text-align:left}
tfoot td{font-weight:700;background:#e6dfcf}
.pricebox{
border:3px solid var(--ink);margin-top:16px;padding:16px;
background:var(--paper);
}
.pricebox .big{font-size:2.2rem;font-weight:700;color:var(--rev)}
.notes{font-size:.8rem;color:var(--brown);font-style:italic;margin-top:6px}
.check{display:block;background:#fff;border:2px solid var(--ink);padding:14px;
font-size:.85rem;margin-top:14px;white-space:pre-wrap}
.btn{display:inline-block;border:1.5px solid var(--ink);padding:8px 14px;
margin:14px 4px 0 0;text-decoration:none;color:var(--ink);font-size:.85rem;font-weight:700}
.btn:hover{background:var(--ink);color:var(--paper)}
footer{border-top:3px double var(--ink);margin-top:34px;padding-top:14px;
font-size:.8rem;color:var(--brown)}
.garnish{font-size:.75rem;color:var(--line);text-align:center;margin-top:20px;letter-spacing:2px}
</style>
<script defer src="https://analytics.4ort.xyz/script.js" data-website-id="d3ed927c-888a-4a6c-ae5f-0b1c613ddf5b"></script>
</head>
<body>
<div class="wrap">
<header class="ticket">
<h1>RECIPE COSTER</h1> <span class="stamp">TICKET #47</span>
<div class="sub">Rony Thomas · Kitchen Operations · La Grange, Kentucky — the math behind a calm, profitable dinner line.</div>
</header>
<nav class="site">
<a href="index.html">← Home</a>
<a href="about.html">About</a>
<a href="#ingredients">Ingredients</a>
<a href="#rules">House Rules</a>
<a href="#checklist">Ticket Checklist</a>
</nav>
<p style="font-size:.9rem">Every plate that leaves the line carries two prices: what it <b>ought to cost</b> and what the <b>customer is willing to pay</b>. The space between them is your margin — and a menu dies in that space, either giving away profit or pushing a number nobody'll order. Cost your plate <i>before</i> it's on the menu, run the yield on every cut, and you'll sleep through a rush. This tool does exactly what my prep sheet does — with no spreadsheet panic.</p>
<fieldset id="ingredients">
<legend>Step 1 · The Plate Build</legend>
<p style="font-size:.8rem;margin-top:0">Portions are in <b>grams</b>; pay what you actually paid per pack/box, bought-as weight. (See the yield rule below before trusting a number.)</p>
<div id="ingRows"></div>
<button id="addIng" type="button">+ add an ingredient</button>
<p style="font-size:.8rem;margin:10px 0 0;color:var(--brown)">Yield %: 100 = buy it, use it all. 65 = a pork shoulder that loses trim &amp; bone to the smoker. The cost per usable gram is recomputed from your yield — this is where kitchens quietly bleed.</p>
</fieldset>
<fieldset>
<legend>Step 2 · Portion &amp; Overhead</legend>
<div class="grid">
<div>
<label for="portions">Portions this recipe makes</label>
<input id="portions" type="number" value="4" min="1">
</div>
<div>
<label for="plate">Ingredient cost per portion (auto)</label>
<input id="plate" type="text" readonly style="background:#e6dfcf" value="—">
</div>
</div>
<div class="grid">
<div>
<label for="overhead">Packaging &amp; small-ware per portion ($)</label>
<input id="overhead" type="number" step="0.01" value="0.35">
</div>
<div>
<label for="labor">Direct labor per portion ($)</label>
<input id="labor" type="number" step="0.01" value="1.10">
</div>
</div>
</fieldset>
<fieldset>
<legend>Step 3 · Price It</legend>
<label for="targetPct">Target food-cost % (industry: 2834 for a casual dining house)</label>
<input id="targetPct" type="number" step="0.5" value="30">
<p style="font-size:.75rem;color:var(--brown)">A plate that's 38% food cost is a house special that feeds guests, not the books. A plate at 22% is either wasting the product or underwhelming the plate.</p>
</fieldset>
<div class="pricebox">
<div>TRUE INGREDIENT COST / PORTION</div>
<div class="big" id="costOut">$0.00</div>
<div style="margin-top:8px">TOTAL COST / PORTION (ingredient + pack + labor)</div>
<div style="font-size:1.5rem;font-weight:700" id="totalOut">$0.00</div>
<div style="margin-top:8px">SELL PRICE AT TARGET FOOD-COST %</div>
<div style="font-size:1.3rem;font-weight:700" id="sellOut">$0.00</div>
<div style="margin-top:8px;color:var(--accent);font-weight:700" id="marginOut"></div>
</div>
<table id="itemsTable" style="margin-top:18px">
<thead><tr><th>Item</th><th>Grams / portion</th><th>Cost / portion</th></tr></thead>
<tbody id="itemsBody"></tbody>
<tfoot><tr><td colspan="2">Ingredient subtotal / portion</td><td id="subtotalOut">$0.00</td></tr></tfoot>
</table>
<fieldset id="rules">
<legend>House Rules I Run Every Plate Through</legend>
<ol style="font-size:.9rem;margin:0;padding-left:20px">
<li><b>Yield is a confession.</b> If you paid for a 10 lb brisket and only 6.5 lb of cooked sliceable meat lands on the board, that 35% shrink is <i>in the plate</i> from the minute you buy it. Cost on cooked yield, always.</li>
<li><b>The garnish is money.</b> A lime wedge, a sauce side, a pickled onion — thirty cents across four hundred covers is <b>$120/night</b> walking off the pass. Nothing leaves free.</li>
<li><b>Benchmark your own menu.</b> Pull every plate's true food-cost % once a quarter and sort high-to-low. The dish nobody orders <i>and</i> that bleeds cost gets cut, not defended.</li>
<li><b>Price to the guest, not the pride.</b> A $28 'heritage pork chop' at 40% food cost is charity; a $19 burger at 26% is a workhorse that turns the room. Know which you actually need.</li>
<li><b>Salt, spice, and chef's taste don't count — yet.</b> They're <i>labor and seasonings</i>, and if you stop tracking them they'll quietly buy a new fryer.</li>
</ol>
</fieldset>
<div class="check" id="checklist">
__ TICKET CHECKLIST — BEFORE IT HITS THE MENU __
[ ] Every ingredient costed on COOKED yield, not bought weight
[ ] Trash &amp; trim accounted for (bones, peel, wilt)
[ ] Garnish and sauce sides itemized as their own line
[ ] Overhead/labor loaded — menu price is not just ingredients
[ ] Food-cost % inside our 2834 band (or deliberately a loss leader)
[ ] One person owns the cost sheet and re-checks it on every vendor price hike
[ ] Rework the numbers monthly — cost is a live animal, not a framed plaque
</div>
<div style="margin:18px 0">
<b>Why this matters to a neighborhood:</b> a kitchen that costs its plates honestly can keep its prices fair, pay its crew, and still stand. A kitchen that guesses makes the guest pay for the guess — one way or the other. Feed people well <i>and</i> keep the doors open; that's the whole craft.
</div>
<a class="btn" href="coster.json" download>Download data twin (.json)</a>
<a class="btn" href="#top">Top</a>
<footer>
Rony Thomas · La Grange, KY · <a href="https://rony-thomas.4ort.net" style="color:inherit">rony-thomas.4ort.net</a> · costed with conscience
</footer>
<div class="garnish">— small batch · run your yield · pay your crew —</div>
</div>
<script>
(function(){
const UNITS={g:1,kg:1000,oz:28.35,lb:453.592};
// starter pantry so the page looks alive before you type
const seed=[
{name:"Pork shoulder (butt)",unit:"lb",grams:1360,price:17.90,yield:65},
{name:"Collard greens, bunch",unit:"lb",grams:907,price:3.20,yield:90},
{name:"Yellow onions",unit:"lb",grams:907,price:1.60,yield:88},
{name:"Apple cider vinegar",unit:"gal",grams:3785,price:6.80,yield:100},
{name:"Seasoning blend (house)",unit:"lb",grams:454,price:4.10,yield:100}
];
let ings=[];
const rowsEl=document.getElementById('ingRows');
const tSel=document.getElementById('addIng');
function fmt(n){return '$'+n.toFixed(2);}
function render(){
rowsEl.innerHTML='';
ings.forEach((it,i)=>{
const row=document.createElement('div');
row.className='row';
row.innerHTML=
'<input class="nm" type="text" placeholder="Ingredient" value="'+esc(it.name)+'">'+
'<select class="un">'+Object.keys(UNITS).map(u=>'<option'+(u===it.unit?' selected':'')+'>'+u+'</option>').join('')+'</select>'+
'<input class="qty" type="number" step="any" min="0.001" placeholder="bought amt" value="'+it.grams+'">'+
'<input class="px" type="number" step="any" min="0" placeholder="price $" value="'+it.price+'">'+
'<input class="yl" type="number" step="1" min="1" max="100" placeholder="yield %" value="'+it.yield+'">'+
'<button class="remove" data-i="'+i+'" type="button">×</button>';
rowsEl.appendChild(row);
});
// attach live handlers
rowsEl.querySelectorAll('.nm').forEach((el,i)=>el.oninput=e=>{ings[i].name=e.target.value});
rowsEl.querySelectorAll('.un').forEach((el,i)=>el.onchange=e=>{ings[i].unit=e.target.value});
rowsEl.querySelectorAll('.qty').forEach((el,i)=>el.oninput=e=>{ings[i].grams=parseFloat(e.target.value)||0});
rowsEl.querySelectorAll('.px').forEach((el,i)=>el.oninput=e=>{ings[i].price=parseFloat(e.target.value)||0});
rowsEl.querySelectorAll('.yl').forEach((el,i)=>el.oninput=e=>{ings[i].yield=parseFloat(e.target.value)||100});
rowsEl.querySelectorAll('.remove').forEach(el=>el.onclick=()=>{ings.splice(+el.dataset.i,1);render();compute();});
}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function compute(){
const portions=Math.max(1,parseFloat(document.getElementById('portions').value)||1);
const overhead=parseFloat(document.getElementById('overhead').value)||0;
const labor=parseFloat(document.getElementById('labor').value)||0;
const target=parseFloat(document.getElementById('targetPct').value)||30;
let totalGrams=0, subtotal=0;
const tbody=document.getElementById('itemsBody');
tbody.innerHTML='';
ings.forEach(it=>{
const kgBought=it.grams/1000;
const costPerKg=kgBought>0? it.price/kgBought : 0;
const yieldFrac=(it.yield||100)/100;
// usable kg = bought kg * yield; cost per usable gram
const usableKg=kgBought*yieldFrac;
const perG=usableKg>0? it.price/(usableKg*1000) : 0;
totalGrams+=it.grams;
const costPerPortion=perG*it.grams;
subtotal+=costPerPortion;
const row=document.createElement('tr');
row.innerHTML='<td>'+esc(it.name)+'</td><td>'+it.grams+'</td><td>'+fmt(costPerPortion)+'</td>';
tbody.appendChild(row);
});
const plateCost=subtotal; // per portion, ingredient
const total=plateCost+overhead+labor;
const sellPrice=target>0? total/(target/100) : 0;
const fcp=total>0? (plateCost/total)*100 : 0;
document.getElementById('plate').value=fmt(plateCost);
document.getElementById('costOut').textContent=fmt(plateCost);
document.getElementById('totalOut').textContent=fmt(total);
document.getElementById('sellOut').textContent=fmt(sellPrice);
document.getElementById('subtotalOut').textContent=fmt(subtotal);
document.getElementById('marginOut').textContent=
'Food-cost share of total: '+fcp.toFixed(1)+'% · gross margin per plate: '+fmt(total>0? sellPrice-total : 0);
// keep the json twin fresh
const twin={
generator:"Rony Thomas Recipe Coster",
portions, targetFoodCostPct:target,
ingredientSubtotalPerPortion:round(plateCost),
overheadPerPortion:round(overhead),
laborPerPortion:round(labor),
totalCostPerPortion:round(total),
sellPriceAtTarget:round(sellPrice),
ingredients:ings.map(it=>{
const kgBought=it.grams/1000;
const usableKg=kgBought*((it.yield||100)/100);
return {name:it.name, boughtGrams:it.grams, buyPrice:it.price,
yieldPct:it.yield, perGram:usableKg>0?id3(round(it.price/(usableKg*1000))):null};
})
};
window.__twin=twin;
}
function round(n){return Math.round(n*100)/100}
function id3(n){return n}
function id(x){return x}
// keep json tied to latest inputs
setInterval(()=>{ if(window.__twin){ /* warm */ } },1e6);
// recompute decoratively on mark; the real twin is rebuilt at save time too
document.addEventListener('input',compute);
// seed
ings=seed.map(s=>({name:s.name,unit:s.unit,grams:s.grams,price:s.price,yield:s.yield}));
tSel.onclick=()=>{ings.push({name:'',unit:'lb',grams:500,price:0,yield:90});render();compute();};
render();compute();
})();
</script>
</body>
</html>