Model seed + variable costs, monthly overhead, Margin of Safety, and full profitability. Multi-crop supported.
Step 0 — Global settings
Step 5 — Results
Step 4 — Margin of Safety (stress test)
| Per month |
| Variable costs | $0.00 |
| Gross profit | $0.00 |
| Fixed overhead | $0.00 |
| Net profit | $0.00 |
| Break-even revenue | $0.00 |
| Break-even trays / month | 0 |
| Stressed net profit | $0.00 |
| Stressed Margin of Safety | 0% |
MOS = (Actual Sales − Break-even Sales) ÷ Actual Sales. A higher MOS means you can survive slower months more easily.
`;
el.innerHTML = baseHTML;
const rowsWrap = document.getElementById("mgpc-rows");
const currencySel = document.getElementById("mgpc-currency");
const fixedEl = document.getElementById("mgpc-fixed");
const autoScrollEl = document.getElementById("mgpc-autoscroll");
const dropEl = document.getElementById("mgpc-drop");
const costUpEl = document.getElementById("mgpc-costup");
const errEl = document.getElementById("mgpc-err");
let state = {
currency: "USD",
fixed: 350,
rows: [
// prefill one row using Broccoli preset
presetRow("Broccoli")
]
};
function presetRow(name){
const p = VARIETIES.find(v=>v.name===name) || VARIETIES[0];
return {
variety:p.name,
tpw:10,
days:p.days,
seed_g:p.seed_g,
seed_perkg:30,
yield_oz:p.yield_oz,
price_oz:p.price_per_oz,
sellthrough:95,
pack_tray:0.75,
medium_tray:1.25,
consum_tray:0.20,
util_tray:0.30,
labor_min:8,
labor_hr:15
};
}
function renderRows(){
rowsWrap.innerHTML = state.rows.map((_,i)=>rowTemplate(i)).join("");
// hydrate inputs
state.rows.forEach((r,i)=>{
const card = rowsWrap.querySelector(`[data-row="${i}"]`);
card.querySelector(`[data-k="variety"]`).value = r.variety;
Object.keys(r).forEach(k=>{
const inp = card.querySelector(`[data-k="${k}"]`);
if(inp && inp.tagName==="INPUT") inp.value = r[k];
});
// variety change -> apply preset (seed_g, days, yield, price)
card.querySelector(`[data-k="variety"]`).addEventListener("change", (e)=>{
const v = e.target.value;
const p = VARIETIES.find(x=>x.name===v);
if(p){
r.variety = v;
r.seed_g = p.seed_g;
r.days = p.days;
r.yield_oz = p.yield_oz;
r.price_oz = p.price_per_oz;
// push to UI
card.querySelector(`[data-k="seed_g"]`).value = r.seed_g;
card.querySelector(`[data-k="days"]`).value = r.days;
card.querySelector(`[data-k="yield_oz"]`).value = r.yield_oz;
card.querySelector(`[data-k="price_oz"]`).value = r.price_oz;
}
computeAndRender(true);
});
// generic input change
card.querySelectorAll("input").forEach(inp=>{
inp.addEventListener("input", ()=>{
const k = inp.getAttribute("data-k");
r[k] = Number(inp.value);
computeAndRender(true);
});
});
// remove
card.querySelector(`[data-remove="${i}"]`).addEventListener("click", ()=>{
state.rows.splice(i,1);
if(state.rows.length===0) state.rows.push(presetRow("Broccoli"));
renderRows();
computeAndRender(false);
});
});
}
function safeNum(x){ return isFinite(x) ? x : 0; }
function computeRow(r){
const sym = currencySymbols[state.currency] || "$";
const tpw = safeNum(r.tpw);
const traysMonth = tpw * weeksPerMonth;
// Seed cost: grams * (per kg / 1000)
const seedCost = safeNum(r.seed_g) * (safeNum(r.seed_perkg)/1000);
// Labor cost: minutes * hourly / 60
const laborCost = (safeNum(r.labor_min)/60) * safeNum(r.labor_hr);
const varCostTray =
seedCost +
safeNum(r.pack_tray) +
safeNum(r.medium_tray) +
safeNum(r.consum_tray) +
safeNum(r.util_tray) +
laborCost;
const sellFactor = Math.max(0, Math.min(1, safeNum(r.sellthrough)/100));
const revenueTray = safeNum(r.yield_oz) * safeNum(r.price_oz) * sellFactor;
const gpTray = revenueTray - varCostTray;
return {
traysMonth,
seedCost,
laborCost,
varCostTray,
revenueTray,
gpTray,
revenueMonth: revenueTray * traysMonth,
varMonth: varCostTray * traysMonth,
gpMonth: gpTray * traysMonth,
sym
};
}
function computeTotals(){
const fixed = safeNum(state.fixed);
const sym = currencySymbols[state.currency] || "$";
const rows = state.rows.map(computeRow);
const revenue = rows.reduce((a,b)=>a+b.revenueMonth,0);
const varCost = rows.reduce((a,b)=>a+b.varMonth,0);
const gp = revenue - varCost;
const net = gp - fixed;
const gmRatio = revenue>0 ? (gp/revenue) : 0;
// Break-even revenue (fixed / gross margin ratio)
const breakEvenRevenue = (gmRatio>0) ? (fixed/gmRatio) : Infinity;
// Break-even trays/month estimate (fixed / weighted avg gross profit per tray)
const totalTraysMonth = rows.reduce((a,b)=>a+b.traysMonth,0);
const gpPerTrayWeighted = totalTraysMonth>0 ? (gp/totalTraysMonth) : 0;
const breakEvenTrays = (gpPerTrayWeighted>0) ? (fixed/gpPerTrayWeighted) : Infinity;
// Margin of Safety
const mos = (revenue>0 && isFinite(breakEvenRevenue)) ? ((revenue - breakEvenRevenue)/revenue) : 0;
// Stress test
const drop = Math.max(0, Math.min(1, safeNum(Number(dropEl.value))/100));
const costUp = Math.max(0, safeNum(Number(costUpEl.value))/100);
const stressedRevenue = revenue * (1 - drop);
const stressedVar = varCost * (1 + costUp) * (1 - drop); // assume volume drops w/ sales
const stressedGp = stressedRevenue - stressedVar;
const stressedNet = stressedGp - fixed;
const stressedGm = stressedRevenue>0 ? (stressedGp/stressedRevenue) : 0;
const stressedBER = (stressedGm>0) ? (fixed/stressedGm) : Infinity;
const stressedMOS = (stressedRevenue>0 && isFinite(stressedBER)) ? ((stressedRevenue - stressedBER)/stressedRevenue) : 0;
return {
sym, fixed, rows, revenue, varCost, gp, net,
gmRatio, breakEvenRevenue, breakEvenTrays,
mos, stressedNet, stressedMOS
};
}
function setText(id, txt){ const e=document.getElementById(id); if(e) e.textContent=txt; }
function computeAndRender(fromInput){
errEl.style.display="none";
const t = computeTotals();
const sym = t.sym;
// KPIs
setText("k-rev", fmtMoney(t.revenue, sym));
setText("k-net", fmtMoney(t.net, sym));
setText("k-gm", Math.round(t.gmRatio*100) + "%");
setText("k-mos", Math.round(t.mos*100) + "%");
// Breakdown
setText("r-var", fmtMoney(t.varCost, sym));
setText("r-gp", fmtMoney(t.gp, sym));
setText("r-fixed", fmtMoney(t.fixed, sym));
setText("r-net", fmtMoney(t.net, sym));
setText("r-ber", isFinite(t.breakEvenRevenue) ? fmtMoney(t.breakEvenRevenue, sym) : "—");
setText("r-bet", isFinite(t.breakEvenTrays) ? fmtNum(t.breakEvenTrays,0) : "—");
setText("r-snet", fmtMoney(t.stressedNet, sym));
setText("r-smos", Math.round(t.stressedMOS*100) + "%");
// Optional autoscroll
if(fromInput && autoScrollEl.checked){
document.getElementById("mgpc-results").scrollIntoView({behavior:"smooth", block:"start"});
}
// Basic sanity warning
if(t.revenue<=0){
errEl.textContent = "Revenue is $0. Increase trays/week, yield, price, or sell-through.";
errEl.style.display="block";
}
}
// Events
currencySel.addEventListener("change", ()=>{
state.currency = currencySel.value;
computeAndRender(false);
});
fixedEl.addEventListener("input", ()=>{
state.fixed = Number(fixedEl.value);
computeAndRender(true);
});
dropEl.addEventListener("input", ()=>computeAndRender(true));
costUpEl.addEventListener("input", ()=>computeAndRender(true));
document.getElementById("mgpc-add").addEventListener("click", ()=>{
state.rows.push(presetRow("Radish"));
renderRows();
computeAndRender(false);
});
document.getElementById("mgpc-reset").addEventListener("click", ()=>{
state.currency="USD";
state.fixed=350;
state.rows=[presetRow("Broccoli")];
currencySel.value="USD";
fixedEl.value="350";
dropEl.value="15";
costUpEl.value="10";
autoScrollEl.checked=false;
renderRows();
computeAndRender(false);
});
document.getElementById("mgpc-copy").addEventListener("click", async ()=>{
const t = computeTotals();
const sym = t.sym;
const text =
`Microgreens Profit Summary
Monthly revenue: ${fmtMoney(t.revenue, sym)}
Monthly variable costs: ${fmtMoney(t.varCost, sym)}
Monthly gross profit: ${fmtMoney(t.gp, sym)}
Monthly fixed overhead: ${fmtMoney(t.fixed, sym)}
Monthly net profit: ${fmtMoney(t.net, sym)}
Gross margin: ${Math.round(t.gmRatio*100)}%
Margin of Safety: ${Math.round(t.mos*100)}%`;
try{
await navigator.clipboard.writeText(text);
alert("Copied summary to clipboard.");
}catch(e){
alert(text);
}
});
// init
currencySel.value = state.currency;
fixedEl.value = state.fixed;
renderRows();
computeAndRender(false);
})();