cybermax / platesprig /app.js
CyberMax-tools's picture
Deploy platesprig to the hub
7947900 verified
Raw History Blame Contribute Delete
43 kB
/* Platesprig app: ingredient library, recipes, costing, pricing, impact, menu matrix, print cards, CSV, backup.
* Everything stays in this browser. © 2026 CyberMax. All rights reserved. */
(function () {
"use strict";
var C = window.PlateCost, X = window.CMXPro;
var KEY = "platesprig:v1";
var FREE_RECIPES = 5, FREE_INGREDIENTS = 25;
var HUB = "cybermax-tools-cybermax.static.hf.space/platesprig/";
var $ = function (id) { return document.getElementById(id); };
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
function pro() { return X.isPro(); }
function toast(m) { X.toast(m); }
function upsell(msg, from) { X.upsell(msg || "That is a Platesprig Pro feature.", from); }
function uid(p) { return p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
/* ---------- state ---------- */
function fresh() { return { settings: { currency: "USD", target: 30, ending: ".99", taxIncl: 0 }, ingredients: [], recipes: [], current: null, dirty: false, period: "", lastImport: null }; }
function load() {
var s = null;
try { s = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { s = null; }
if (!s || typeof s !== "object" || !Array.isArray(s.recipes)) return null;
var f = fresh();
Object.keys(f).forEach(function (k) { if (s[k] == null) s[k] = f[k]; });
return s;
}
var S = load();
var saveT = null;
function save() {
clearTimeout(saveT);
saveT = setTimeout(function () { try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { toast("Could not save in this browser (storage full or private mode)."); } }, 120);
}
function touch() { S.dirty = true; save(); }
function loadExample(kind) {
var ex = C.example(kind), cur = S && S.settings && S.settings.currency;
S = fresh();
S.settings = ex.settings; if (cur && kind !== "bakery" && cur !== "USD") S.settings.currency = cur;
S.ingredients = ex.ingredients; S.recipes = ex.recipes;
S.current = kind === "bakery" ? "k1" : "r2";
S.example = kind;
save();
}
if (!S) loadExample("cafe");
function ctx() { return C.index(S); }
function cur() { return S.settings.currency || "USD"; }
function money(n, dp) { return C.money(n, cur(), dp); }
function recipe() { var r = S.recipes.filter(function (x) { return x.id === S.current; })[0]; if (!r && S.recipes.length) { r = S.recipes[0]; S.current = r.id; } return r || null; }
function ingById(id) { return S.ingredients.filter(function (i) { return i.id === id; })[0]; }
function recById(id) { return S.recipes.filter(function (i) { return i.id === id; })[0]; }
/* ---------- settings ---------- */
$("s-currency").innerHTML = C.CURRENCIES.map(function (c) { return '<option value="' + c[0] + '">' + c[0] + " · " + esc(c[1]) + "</option>"; }).join("");
function settingsToForm() {
$("s-target").value = S.settings.target; $("s-ending").value = S.settings.ending || "none";
$("s-tax").value = S.settings.taxIncl ? S.settings.taxIncl : ""; $("s-currency").value = cur();
}
$("s-target").addEventListener("input", function () { var v = C.parseNum(this.value); S.settings.target = v > 0 && v < 100 ? v : 30; touch(); renderAll(); });
$("s-ending").addEventListener("change", function () { S.settings.ending = this.value; touch(); renderAll(); });
$("s-tax").addEventListener("input", function () { var v = C.parseNum(this.value); S.settings.taxIncl = v >= 0 && v < 100 ? v : 0; touch(); renderAll(); });
$("s-currency").addEventListener("change", function () { S.settings.currency = this.value; touch(); renderAll(); });
/* ---------- tabs ---------- */
var TABS = ["recipes", "ingredients", "impact", "matrix"];
function showTab(name, focus) {
TABS.forEach(function (t) {
var on = t === name;
$("tab-" + t).setAttribute("aria-selected", on ? "true" : "false");
$("tab-" + t).tabIndex = on ? 0 : -1;
$("p-" + t).hidden = !on;
});
if (focus) $("tab-" + name).focus();
if (name === "impact") renderImpact();
if (name === "matrix") renderMatrix();
if (name === "ingredients") renderIngredients();
}
TABS.forEach(function (t, i) {
$("tab-" + t).addEventListener("click", function () { showTab(t); });
$("tab-" + t).addEventListener("keydown", function (e) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") { e.preventDefault(); showTab(TABS[(i + (e.key === "ArrowRight" ? 1 : TABS.length - 1)) % TABS.length], true); }
});
});
Array.prototype.forEach.call(document.querySelectorAll("[data-upsell]"), function (b) {
b.addEventListener("click", function () { upsell(null, b.getAttribute("data-upsell")); });
});
/* ---------- recipe list ---------- */
function fcClass(fc) { if (fc == null) return "none"; return fc <= S.settings.target + 1e-9 ? "ok" : "over"; }
function renderList() {
var cx = ctx();
$("rec-list").innerHTML = S.recipes.map(function (r) {
var s = C.summary(r, cx, S.settings), cls = fcClass(s.foodCost);
return '<button type="button" class="rec' + (r.id === S.current ? " on" : "") + '" data-id="' + esc(r.id) + '" aria-pressed="' + (r.id === S.current) + '"><b>' + esc(r.name || "Untitled recipe") +
'</b><span>' + money(s.portion) + " / portion" + (s.foodCost != null ? ' · <i class="fc ' + cls + '">' + C.pct(s.foodCost) + "</i>" : "") + "</span></button>";
}).join("") || '<p class="hint">No recipes yet. Add one, or load an example above.</p>';
var n = S.recipes.length;
$("rec-limit").textContent = pro() ? n + " recipes · unlimited with Pro" : n + " of " + FREE_RECIPES + " free recipes used";
$("rec-limit").className = "limit" + (!pro() && n >= FREE_RECIPES ? " out" : "");
$("btn-dup-recipe").disabled = $("btn-del-recipe").disabled = !recipe();
}
$("rec-list").addEventListener("click", function (e) {
var b = e.target.closest(".rec"); if (!b) return;
S.current = b.getAttribute("data-id"); save(); renderRecipe();
});
function canAddRecipe() {
if (pro() || S.recipes.length < FREE_RECIPES) return true;
upsell("The free plan saves " + FREE_RECIPES + " recipes. Pro gives you unlimited recipes.", "recipe-limit");
return false;
}
$("btn-new-recipe").addEventListener("click", function () {
if (!canAddRecipe()) return;
var r = { id: uid("r"), name: "New recipe", category: "", portions: 1, batchQty: "", batchUnit: "", price: "", sold: "", method: "", lines: [] };
S.recipes.push(r); S.current = r.id; touch(); renderRecipe();
$("r-name").focus(); $("r-name").select();
});
$("btn-dup-recipe").addEventListener("click", function () {
var r = recipe(); if (!r || !canAddRecipe()) return;
var c = clone(r); c.id = uid("r"); c.name = r.name + " (copy)";
S.recipes.push(c); S.current = c.id; touch(); renderRecipe(); toast("Recipe duplicated.");
});
$("btn-del-recipe").addEventListener("click", function () {
var r = recipe(); if (!r) return;
var users = S.recipes.filter(function (x) { return (x.lines || []).some(function (l) { return l.type === "rec" && l.ref === r.id; }); });
if (!confirm("Delete “" + r.name + "”?" + (users.length ? " It is used as a sub-recipe in " + users.map(function (u) { return u.name; }).join(", ") + "." : ""))) return;
S.recipes = S.recipes.filter(function (x) { return x.id !== r.id; });
S.current = S.recipes.length ? S.recipes[0].id : null; touch(); renderRecipe();
});
/* ---------- recipe editor ---------- */
var form = $("rec-form");
$("r-batch-unit").innerHTML = '<option value="">unit</option>' + C.UNIT_LIST.map(function (u) { return "<option>" + u + "</option>"; }).join("");
function recipeToForm() {
var r = recipe();
form.hidden = !r; $("result").hidden = !r;
if (!r) return;
$("r-name").value = r.name || ""; $("r-category").value = r.category || ""; $("r-portions").value = r.portions || "";
$("r-price").value = r.price === "" || r.price == null ? "" : Number(r.price).toFixed(2); $("r-batch").value = r.batchQty || ""; $("r-batch-unit").value = r.batchUnit || "";
$("r-method").value = r.method || "";
$("dl-cats").innerHTML = uniq(S.recipes.map(function (x) { return x.category; })).map(function (c) { return '<option value="' + esc(c) + '">'; }).join("");
}
function uniq(a) { var o = []; a.forEach(function (x) { if (x && o.indexOf(x) < 0) o.push(x); }); return o; }
form.addEventListener("input", function (e) {
var r = recipe(), el = e.target; if (!r || !el.name || el.closest(".line")) return;
if (el.name === "portions" || el.name === "batchQty") r[el.name] = el.value.trim() === "" ? "" : C.parseNum(el.value);
else if (el.name === "price") r.price = el.value.trim() === "" ? "" : C.parseNum(el.value);
else r[el.name] = el.value;
touch(); renderResult(); renderList(); renderMenuTable();
if (el.name === "batchQty") renderLines();
});
form.addEventListener("change", function (e) {
var r = recipe(), el = e.target; if (!r || el.name !== "batchUnit") return;
r.batchUnit = el.value; touch(); renderAll();
});
form.addEventListener("submit", function (e) { e.preventDefault(); });
function itemOptions(r, line) {
var opts = '<option value="">Choose an ingredient…</option><optgroup label="Ingredients">' +
S.ingredients.slice().sort(function (a, b) { return a.name.localeCompare(b.name); }).map(function (i) {
return '<option value="i:' + esc(i.id) + '"' + (line.type !== "rec" && line.ref === i.id ? " selected" : "") + ">" + esc(i.name) + "</option>";
}).join("") + "</optgroup>";
var subs = S.recipes.filter(function (x) { return x.id !== r.id && !C.wouldLoop(S, r.id, x.id); });
if (subs.length) opts += '<optgroup label="Sub-recipes' + (pro() ? "" : " (Pro)") + '">' + subs.map(function (x) {
return '<option value="r:' + esc(x.id) + '"' + (line.type === "rec" && line.ref === x.id ? " selected" : "") + ">" + esc(x.name) + (pro() ? "" : " (Pro)") + "</option>";
}).join("") + "</optgroup>";
return opts;
}
function unitOptions(line) {
var list = C.UNIT_LIST;
if (line.type === "rec") { var sr = recById(line.ref); list = sr ? C.subUnits(sr) : ["portion"]; }
else { var ing = ingById(line.ref), c = ing && C.ingCost(ing); if (c) list = C.unitsFor(c.dim, ing.density); }
if (line.unit && list.indexOf(line.unit) < 0) list = list.concat(line.unit);
return list.map(function (u) { return "<option" + (u === line.unit ? " selected" : "") + ">" + u + "</option>"; }).join("");
}
function renderLines() {
var r = recipe(); if (!r) { $("lines").innerHTML = ""; return; }
$("lines").innerHTML = (r.lines || []).map(function (l, i) {
return '<div class="line" data-i="' + i + '">' +
'<div class="l-item"><label class="sr" for="ln' + i + '-item">Item</label><select id="ln' + i + '-item" data-f="item">' + itemOptions(r, l) + "</select></div>" +
'<div class="l-qty"><label class="sr" for="ln' + i + '-qty">Quantity</label><input id="ln' + i + '-qty" data-f="qty" inputmode="decimal" value="' + esc(l.qty === "" ? "" : C.fmtQty(l.qty)) + '"></div>' +
'<div class="l-unit"><label class="sr" for="ln' + i + '-unit">Unit</label><select id="ln' + i + '-unit" data-f="unit">' + unitOptions(l) + "</select></div>" +
'<div class="l-cost" data-cost></div>' +
'<div class="l-del"><button type="button" class="icon-btn" data-act="del" aria-label="Remove line">×</button></div>' +
'<div class="l-err" data-err></div></div>';
}).join("") || '<p class="hint">No ingredients in this recipe yet.</p>';
updateLineCosts();
}
function updateLineCosts() {
var r = recipe(); if (!r) return;
var res = C.costRecipe(r, ctx());
Array.prototype.forEach.call($("lines").querySelectorAll(".line"), function (row) {
var l = res.lines[+row.getAttribute("data-i")]; if (!l) return;
row.querySelector("[data-cost]").textContent = l.ref ? money(l.cost, l.cost < 0.1 && l.cost > 0 ? 3 : 2) : "";
row.querySelector("[data-err]").textContent = l.ref ? l.err : "";
row.classList.toggle("bad", !!(l.ref && l.err));
});
}
$("lines").addEventListener("input", function (e) {
var row = e.target.closest(".line"), r = recipe(); if (!row || !r) return;
var l = r.lines[+row.getAttribute("data-i")];
if (e.target.getAttribute("data-f") === "qty") { l.qty = C.parseNum(e.target.value); touch(); updateLineCosts(); renderResult(); renderList(); renderMenuTable(); }
});
$("lines").addEventListener("change", function (e) {
var row = e.target.closest(".line"), r = recipe(); if (!row || !r) return;
var i = +row.getAttribute("data-i"), l = r.lines[i], f = e.target.getAttribute("data-f");
if (f === "item") {
var v = e.target.value, type = v.charAt(0) === "r" ? "rec" : "ing", ref = v.slice(2);
if (type === "rec" && !pro()) { e.target.value = l.ref ? (l.type === "rec" ? "r:" : "i:") + l.ref : ""; upsell("Sub-recipes (a sauce or dough used inside dishes) are part of Platesprig Pro.", "sub-recipe"); return; }
l.type = type; l.ref = ref;
if (type === "rec") l.unit = "portion";
else { var ing = ingById(ref), c = ing && C.ingCost(ing); if (c) { var ok = C.unitsFor(c.dim, ing.density); if (ok.indexOf(l.unit) < 0) l.unit = c.dim === "count" ? "each" : ok.indexOf(ing.packUnit) > -1 && ing.packUnit !== "kg" && ing.packUnit !== "l" && ing.packUnit !== "gal" && ing.packUnit !== "lb" ? ing.packUnit : (c.dim === "mass" ? "g" : "ml"); } }
if (!(l.qty > 0)) l.qty = 1;
touch(); renderLines(); renderResult(); renderList(); renderMenuTable();
} else if (f === "unit") { l.unit = e.target.value; touch(); updateLineCosts(); renderResult(); renderList(); renderMenuTable(); }
});
$("lines").addEventListener("click", function (e) {
var b = e.target.closest('[data-act="del"]'); if (!b) return;
var r = recipe(), i = +b.closest(".line").getAttribute("data-i");
r.lines.splice(i, 1); touch(); renderLines(); renderResult(); renderList(); renderMenuTable();
});
$("btn-add-line").addEventListener("click", function () {
var r = recipe(); if (!r) return;
r.lines.push({ type: "ing", ref: "", qty: "", unit: "g" }); touch(); renderLines();
var sel = $("lines").querySelector(".line:last-child select"); if (sel) sel.focus();
});
$("btn-add-sub").addEventListener("click", function () {
var r = recipe(); if (!r) return;
if (!pro()) { upsell("Sub-recipes (a sauce or dough used inside dishes) are part of Platesprig Pro.", "sub-recipe"); return; }
var subs = S.recipes.filter(function (x) { return x.id !== r.id && !C.wouldLoop(S, r.id, x.id); });
if (!subs.length) { toast("Make the sauce, dough or dressing as its own recipe first, then add it here."); return; }
r.lines.push({ type: "rec", ref: subs[0].id, qty: 1, unit: "portion" }); touch(); renderLines(); renderResult(); renderList(); renderMenuTable();
});
$("btn-goto-ing").addEventListener("click", function () { showTab("ingredients"); $("p-ingredients").scrollIntoView({ block: "start" }); });
/* ---------- result ---------- */
function renderResult() {
var r = recipe(); if (!r) return;
var s = C.summary(r, ctx(), S.settings), t = S.settings.target;
$("res-name").textContent = r.name || "Untitled recipe";
var p = Number(r.portions) > 0 ? Number(r.portions) : 1;
$("res-sub").textContent = (r.category ? r.category + " · " : "") + "batch makes " + C.fmtQty(p) + (p === 1 ? " portion" : " portions");
$("m-portion").textContent = money(s.portion);
$("m-batch").textContent = p !== 1 ? "Batch cost " + money(s.batch) : "Plate cost";
var fcEl = $("m-fc"), note = $("m-fc-note");
if (s.foodCost == null) { fcEl.textContent = "-"; fcEl.className = ""; note.textContent = "Enter a menu price"; }
else {
fcEl.textContent = C.pct(s.foodCost); fcEl.className = s.onTarget ? "ok" : "over";
note.textContent = (s.onTarget ? "✓ Within" : "▲ Over") + " your " + C.fmtQty(t) + "% target at " + money(s.price);
}
$("m-sugg-label").textContent = "Price for " + C.fmtQty(t) + "% food cost";
$("m-sugg").textContent = s.suggested > 0 ? money(s.suggested) : "-";
$("btn-use-sugg").hidden = !(s.suggested > 0) || Math.abs(s.suggested - s.price) < 0.005;
$("m-margin").textContent = s.margin == null ? "-" : money(s.margin);
$("m-margin-note").textContent = s.margin == null ? "menu price less food cost" : (S.settings.taxIncl ? "after " + C.fmtQty(S.settings.taxIncl) + "% tax and food cost" : "menu price less food cost");
var errs = uniq(s.errors);
$("warns").innerHTML = errs.map(function (e) { return "<li>" + esc(e) + "</li>"; }).join("");
var lines = s.lines.filter(function (l) { return l.ref && l.cost > 0; }).sort(function (a, b) { return b.cost - a.cost; });
var max = lines.length ? lines[0].cost : 0;
$("bars").innerHTML = lines.map(function (l) {
var w = max > 0 ? Math.max(1.5, l.cost / max * 100) : 0;
var tip = l.name + ": " + C.fmtQty(l.qty) + " " + l.unit + " = " + money(l.cost / p, 3) + " per portion (" + C.pct(l.share * 100) + " of the plate)";
return '<div class="bar" role="row" title="' + esc(tip) + '"><span class="b-name" role="cell">' + esc(l.name) + (l.sub ? ' <em>sub-recipe</em>' : "") +
'</span><span class="b-track" role="cell" aria-label="' + esc(C.pct(l.share * 100)) + '"><i style="width:' + w.toFixed(1) + '%"></i></span><span class="b-val" role="cell">' + money(l.cost / p, l.cost / p < 0.1 ? 3 : 2) + ' <small>' + C.pct(l.share * 100, 0) + "</small></span></div>";
}).join("") || '<p class="hint">Add ingredients to see what each one adds to the plate.</p>';
$("pro-cta").hidden = pro() || !(s.portion > 0);
}
$("btn-use-sugg").addEventListener("click", function () {
var r = recipe(); if (!r) return;
var s = C.summary(r, ctx(), S.settings);
if (s.suggested > 0) { r.price = s.suggested; $("r-price").value = s.suggested; touch(); renderResult(); renderList(); renderMenuTable(); toast("Menu price set to " + money(s.suggested) + "."); }
});
/* ---------- menu table ---------- */
function renderMenuTable() {
var cx = ctx(), t = S.settings.target;
var rows = S.recipes.map(function (r) {
var s = C.summary(r, cx, S.settings);
return "<tr><td>" + esc(r.name) + '</td><td class="num">' + money(s.portion) + '</td><td class="num">' + (s.price > 0 ? money(s.price) : "-") +
'</td><td class="num"><span class="fc ' + fcClass(s.foodCost) + '">' + (s.foodCost == null ? "-" : C.pct(s.foodCost)) + '</span></td><td class="num">' + (s.suggested > 0 ? money(s.suggested) : "-") +
'</td><td class="num">' + (s.margin == null ? "-" : money(s.margin)) + "</td></tr>";
}).join("");
$("menu-table").innerHTML = '<thead><tr><th scope="col">Dish</th><th scope="col" class="num">Cost / portion</th><th scope="col" class="num">Menu price</th><th scope="col" class="num">Food cost</th><th scope="col" class="num">Price at ' + C.fmtQty(t) + '%</th><th scope="col" class="num">Gross profit</th></tr></thead><tbody>' + (rows || '<tr><td colspan="6">No recipes yet.</td></tr>') + "</tbody>";
}
function recipesCSV() {
var cx = ctx();
var rows = [["Recipe", "Category", "Portions", "Batch cost", "Cost per portion", "Menu price", "Food cost %", "Target %", "Suggested price", "Gross profit per portion", "Currency"]];
S.recipes.forEach(function (r) {
var s = C.summary(r, cx, S.settings);
rows.push([r.name, r.category || "", Number(r.portions) || 1, C.round(s.batch, 4), C.round(s.portion, 4), s.price || "", s.foodCost == null ? "" : C.round(s.foodCost, 1), S.settings.target, s.suggested || "", s.margin == null ? "" : C.round(s.margin, 2), cur()]);
});
return C.toCSV(rows);
}
function ingredientsCSV() {
var rows = [["Ingredient", "Pack size", "Unit", "Pack price", "Yield %", "Density g/ml", "Usable cost per unit", "Currency"]];
S.ingredients.forEach(function (i) { rows.push([i.name, i.packQty, i.packUnit, i.packPrice, i.yield, i.density || "", C.round(C.costPer(i, i.packUnit) || 0, 4), cur()]); });
return C.toCSV(rows);
}
function today() { return new Date().toISOString().slice(0, 10); }
$("btn-csv-recipes").addEventListener("click", function () {
if (!pro()) return upsell("CSV export is part of Platesprig Pro.", "csv");
X.download("platesprig-recipe-costs-" + today() + ".csv", recipesCSV()); toast("Recipe costs exported.");
});
$("btn-csv-ing").addEventListener("click", function () {
if (!pro()) return upsell("CSV export is part of Platesprig Pro.", "csv");
X.download("platesprig-ingredients-" + today() + ".csv", ingredientsCSV()); toast("Ingredients exported.");
});
/* ---------- ingredients ---------- */
function usableLabel(i) {
var c = C.ingCost(i); if (!c) return "set pack, unit and price";
var u = c.dim === "count" ? "each" : c.dim === "mass" ? (/^(oz|lb)$/.test(i.packUnit) ? "lb" : "kg") : (/^(fl oz|cup|qt|gal|tbsp|tsp)$/.test(i.packUnit) ? "fl oz" : "l");
return money(C.costPer(i, u), u === "each" || u === "fl oz" ? 3 : 2) + " / " + u;
}
function renderIngredients() {
var q = ($("ing-search").value || "").trim().toLowerCase();
var uses = {};
S.ingredients.forEach(function (i) { uses[i.id] = C.recipesUsing(S, i.id).length; });
var list = S.ingredients.filter(function (i) { return !q || i.name.toLowerCase().indexOf(q) > -1; });
$("ing-list").innerHTML = list.map(function (i) {
var id = esc(i.id);
return '<div class="ing" data-id="' + id + '">' +
'<div class="g-name"><label for="in-' + id + '-name">Ingredient</label><input id="in-' + id + '-name" data-f="name" value="' + esc(i.name) + '"></div>' +
'<div><label for="in-' + id + '-q">Pack size</label><input id="in-' + id + '-q" data-f="packQty" inputmode="decimal" value="' + esc(C.fmtQty(i.packQty)) + '"></div>' +
'<div><label for="in-' + id + '-u">Unit</label><select id="in-' + id + '-u" data-f="packUnit">' + C.UNIT_LIST.map(function (u) { return "<option" + (u === i.packUnit ? " selected" : "") + ">" + u + "</option>"; }).join("") + "</select></div>" +
'<div><label for="in-' + id + '-p">Pack price</label><input id="in-' + id + '-p" data-f="packPrice" inputmode="decimal" value="' + esc(isFinite(i.packPrice) && i.packPrice !== "" ? Number(i.packPrice).toFixed(2) : i.packPrice) + '"></div>' +
'<div><label for="in-' + id + '-y">Yield %</label><input id="in-' + id + '-y" data-f="yield" inputmode="decimal" value="' + esc(i.yield) + '"></div>' +
'<div><label for="in-' + id + '-d">Density g/ml</label><input id="in-' + id + '-d" data-f="density" inputmode="decimal" placeholder="optional" value="' + esc(i.density || "") + '"></div>' +
'<div class="g-cost"><span data-usable>' + esc(usableLabel(i)) + '</span><small>' + (uses[i.id] ? "in " + uses[i.id] + (uses[i.id] === 1 ? " recipe" : " recipes") : "not used yet") + "</small></div>" +
'<div class="g-del"><button type="button" class="icon-btn" data-act="del" aria-label="Delete ' + esc(i.name) + '">×</button></div></div>';
}).join("") || '<p class="hint">' + (q ? "No ingredient matches." : "No ingredients yet. Add what you buy, with the pack size and price.") + "</p>";
var n = S.ingredients.length;
$("ing-limit").textContent = pro() ? n + " ingredients · unlimited with Pro" : n + " of " + FREE_INGREDIENTS + " free ingredients used";
$("ing-limit").className = "limit" + (!pro() && n >= FREE_INGREDIENTS ? " out" : "");
}
$("ing-search").addEventListener("input", renderIngredients);
$("ing-list").addEventListener("input", function (e) {
var row = e.target.closest(".ing"); if (!row) return;
var i = ingById(row.getAttribute("data-id")), f = e.target.getAttribute("data-f"); if (!i || !f) return;
if (f === "name") i.name = e.target.value;
else if (f === "density") i.density = e.target.value.trim() === "" ? "" : C.parseNum(e.target.value);
else if (f !== "packUnit") i[f] = C.parseNum(e.target.value);
touch();
row.querySelector("[data-usable]").textContent = usableLabel(i);
softRefresh();
});
$("ing-list").addEventListener("change", function (e) {
var row = e.target.closest(".ing"); if (!row) return;
var i = ingById(row.getAttribute("data-id"));
if (e.target.getAttribute("data-f") === "packUnit") { i.packUnit = e.target.value; touch(); row.querySelector("[data-usable]").textContent = usableLabel(i); softRefresh(); renderLines(); }
if (e.target.getAttribute("data-f") === "name") renderLines();
});
$("ing-list").addEventListener("click", function (e) {
var b = e.target.closest('[data-act="del"]'); if (!b) return;
var id = b.closest(".ing").getAttribute("data-id"), i = ingById(id), used = C.recipesUsing(S, id);
if (!confirm("Delete “" + i.name + "”?" + (used.length ? " It is used in " + used.map(function (r) { return r.name; }).join(", ") + "." : ""))) return;
S.ingredients = S.ingredients.filter(function (x) { return x.id !== id; });
touch(); renderAll();
});
$("btn-add-ing").addEventListener("click", function () {
if (!pro() && S.ingredients.length >= FREE_INGREDIENTS) return upsell("The free plan holds " + FREE_INGREDIENTS + " ingredients. Pro gives you unlimited ingredients and supplier CSV import.", "ingredient-limit");
var i = { id: uid("i"), name: "New ingredient", packQty: 1, packUnit: "kg", packPrice: 0, yield: 100, density: "" };
S.ingredients.unshift(i); $("ing-search").value = ""; touch(); renderIngredients();
var el = $("in-" + i.id + "-name"); if (el) { el.focus(); el.select(); }
});
function softRefresh() { renderList(); renderResult(); updateLineCosts(); renderMenuTable(); }
/* CSV import */
$("lbl-import").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("Supplier CSV import is part of Platesprig Pro.", "csv-import"); } });
$("f-import").addEventListener("change", function () {
var f = this.files && this.files[0]; this.value = "";
if (!f) return;
if (!pro()) return upsell("Supplier CSV import is part of Platesprig Pro.", "csv-import");
f.text().then(function (text) {
var res = C.parsePriceList(text);
if (res.error) { $("import-note").textContent = res.error; toast(res.error); return; }
var before = clone({ ingredients: S.ingredients, recipes: S.recipes });
var m = C.mergePriceList(S.ingredients, res.items, function () { return uid("i"); });
var rows = C.compare(before, S, S.settings);
S.lastImport = { at: new Date().toISOString(), file: f.name, before: before.ingredients, changed: m.changed.length };
touch(); renderAll();
var msg = m.updated + " updated, " + m.added + " added" + (res.skipped.length ? ", " + res.skipped.length + " rows skipped" : "") + ". " + m.changed.length + (m.changed.length === 1 ? " price changed" : " prices changed") + ", " + rows.length + (rows.length === 1 ? " dish affected." : " dishes affected.");
$("import-note").textContent = "Imported " + f.name + ": " + msg + (rows.length ? " See Price impact for the details." : "");
toast(msg);
});
});
/* ---------- impact ---------- */
function impactRows(rows, title) {
if (!rows.length) return '<tbody><tr><td>No dish changes cost.</td></tr></tbody>';
return "<caption>" + esc(title) + '</caption><thead><tr><th scope="col">Dish</th><th scope="col" class="num">Plate cost before</th><th scope="col" class="num">After</th><th scope="col" class="num">Change</th><th scope="col" class="num">Food cost now</th><th scope="col" class="num">Price at ' + C.fmtQty(S.settings.target) + "%</th></tr></thead><tbody>" +
rows.map(function (r) {
return "<tr><td>" + esc(r.name) + '</td><td class="num">' + money(r.before) + '</td><td class="num">' + money(r.after) + '</td><td class="num ' + (r.delta > 0 ? "up" : "down") + '">' + (r.delta > 0 ? "+" : "") + money(r.delta) + (r.deltaPct != null ? " (" + (r.delta > 0 ? "+" : "") + C.pct(r.deltaPct) + ")" : "") +
'</td><td class="num">' + (r.fcAfter == null ? "-" : C.pct(r.fcBefore) + " → " + C.pct(r.fcAfter)) + '</td><td class="num">' + (r.suggested > 0 ? money(r.suggested) : "-") + "</td></tr>";
}).join("") + "</tbody>";
}
function renderImpact() {
$("impact-lock").hidden = pro(); $("impact-tool").hidden = !pro();
if (!pro()) return;
var sel = $("imp-ing"), prev = sel.value;
sel.innerHTML = S.ingredients.slice().sort(function (a, b) { return a.name.localeCompare(b.name); }).map(function (i) { return '<option value="' + esc(i.id) + '">' + esc(i.name) + "</option>"; }).join("");
if (prev && ingById(prev)) sel.value = prev;
var i = ingById(sel.value);
if (i && (!$("imp-price").value || $("imp-price").getAttribute("data-for") !== i.id)) { $("imp-price").value = i.packPrice; $("imp-price").setAttribute("data-for", i.id); $("imp-pct").value = ""; }
updateImpact();
var li = S.lastImport;
if (li && li.before) {
var rows = C.compare({ ingredients: li.before, recipes: S.recipes }, S, S.settings);
$("imp-import").innerHTML = '<h3>Since your last price import</h3><p class="hint">' + esc(li.file || "CSV") + ", " + esc(String(li.at).slice(0, 10)) + '</p><div class="table-wrap"><table class="ht">' + impactRows(rows, "Plate cost changes since the import") + "</table></div>";
} else $("imp-import").innerHTML = "";
}
function updateImpact() {
var i = ingById($("imp-ing").value); if (!i) { $("imp-table").innerHTML = ""; return; }
var np = C.parseNum($("imp-price").value);
var rows = C.impact(S, i.id, np, S.settings);
var ch = i.packPrice > 0 ? (np - i.packPrice) / i.packPrice * 100 : 0;
$("imp-note").textContent = i.name + ": " + money(i.packPrice) + " → " + money(np) + " per " + C.fmtQty(i.packQty) + " " + i.packUnit + " (" + (ch >= 0 ? "+" : "") + C.pct(ch) + "). " +
rows.length + (rows.length === 1 ? " dish changes." : " dishes change.");
$("imp-table").innerHTML = impactRows(rows, "What happens to each dish");
}
$("imp-ing").addEventListener("change", function () { $("imp-price").removeAttribute("data-for"); $("imp-price").value = ""; renderImpact(); });
$("imp-price").addEventListener("input", function () { $("imp-pct").value = ""; updateImpact(); });
$("imp-pct").addEventListener("input", function () {
var i = ingById($("imp-ing").value); if (!i) return;
$("imp-price").value = C.round(i.packPrice * (1 + C.parseNum(this.value) / 100), 2); updateImpact();
});
$("btn-imp-apply").addEventListener("click", function () {
var i = ingById($("imp-ing").value); if (!i) return;
i.packPrice = C.parseNum($("imp-price").value); touch(); renderAll(); toast(i.name + " now costs " + money(i.packPrice) + ".");
});
/* ---------- matrix ---------- */
function renderMatrix() {
$("matrix-lock").hidden = pro(); $("matrix-tool").hidden = !pro();
if (!pro()) return;
$("mx-period").value = S.period || "";
var cx = ctx();
var priced = S.recipes.filter(function (r) { return Number(r.price) > 0; });
var me = C.menuEngineering(priced.map(function (r) { return { id: r.id, name: r.name, sold: Number(r.sold) || 0, price: r.price, cost: C.costRecipe(r, cx).portion }; }), S.settings.taxIncl);
var byId = {}; me.rows.forEach(function (r) { byId[r.id] = r; });
$("mx-table").innerHTML = '<thead><tr><th scope="col">Dish</th><th scope="col" class="num">Sold</th><th scope="col" class="num">Mix</th><th scope="col" class="num">Gross profit</th><th scope="col">Class</th><th scope="col">What to do</th></tr></thead><tbody>' +
priced.map(function (r) {
var m = byId[r.id];
return "<tr><td>" + esc(r.name) + '</td><td class="num"><label class="sr" for="mx-' + esc(r.id) + '">Sold: ' + esc(r.name) + '</label><input class="sold" id="mx-' + esc(r.id) + '" data-id="' + esc(r.id) + '" inputmode="numeric" value="' + esc(r.sold === "" || r.sold == null ? "" : r.sold) + '"></td><td class="num">' + C.pct(m.mix) +
'</td><td class="num">' + money(m.cm) + '</td><td><span class="cls cls-' + m.cls + '">' + m.label + '</span></td><td class="act">' + esc(m.action) + "</td></tr>";
}).join("") + "</tbody>";
if (!priced.length) { $("mx-table").innerHTML = "<tbody><tr><td>Give your dishes a menu price first.</td></tr></tbody>"; $("mx-sum").textContent = ""; $("mx-chart").innerHTML = ""; return; }
$("mx-sum").innerHTML = "<b>" + me.totalSold + "</b> items sold" + (S.period ? " in " + esc(S.period) : "") + " · popularity line <b>" + C.pct(me.threshold) + "</b> of the mix (70% × 100% ÷ " + me.n + ") · average gross profit <b>" + money(me.avgCM) + "</b> per portion · total gross profit <b>" + money(me.totalCM) + "</b>" + (me.foodCost != null ? " · menu food cost <b>" + C.pct(me.foodCost) + "</b>" : "");
$("mx-chart").innerHTML = matrixSVG(me);
}
function matrixSVG(me) {
var W = 640, H = 360, L = 70, R = 16, T = 16, B = 40;
var maxX = Math.max(me.threshold * 2, Math.max.apply(null, me.rows.map(function (r) { return r.mix; }).concat([1]))) * 1.1;
var cms = me.rows.map(function (r) { return r.cm; }).concat([me.avgCM]);
var lo = Math.min.apply(null, cms), hi = Math.max.apply(null, cms), span = Math.max(hi - lo, Math.abs(hi) * 0.2, 0.5);
var minY = lo - span * 0.35, maxY = hi + span * 0.35;
var x = function (v) { return L + v / maxX * (W - L - R); }, y = function (v) { return T + (1 - (v - minY) / (maxY - minY)) * (H - T - B); };
var tx = x(me.threshold), ty = y(me.avgCM);
var s = '<svg viewBox="0 0 ' + W + " " + H + '" role="img" aria-label="Menu engineering matrix: ' + esc(me.rows.map(function (r) { return r.name + " " + r.label; }).join(", ")) + '">';
s += '<rect x="' + L + '" y="' + T + '" width="' + (W - L - R) + '" height="' + (H - T - B) + '" class="mx-bg"/>';
s += '<text x="' + (W - R - 6) + '" y="' + (T + 16) + '" text-anchor="end" class="mx-q">Stars</text><text x="' + (L + 6) + '" y="' + (T + 16) + '" class="mx-q">Puzzles</text>';
s += '<text x="' + (W - R - 6) + '" y="' + (H - B - 8) + '" text-anchor="end" class="mx-q">Plowhorses</text><text x="' + (L + 6) + '" y="' + (H - B - 8) + '" class="mx-q">Dogs</text>';
s += '<line x1="' + tx + '" x2="' + tx + '" y1="' + T + '" y2="' + (H - B) + '" class="mx-rule"/><line x1="' + L + '" x2="' + (W - R) + '" y1="' + ty + '" y2="' + ty + '" class="mx-rule"/>';
s += '<text x="' + ((L + W - R) / 2) + '" y="' + (H - 8) + '" text-anchor="middle" class="mx-ax">Share of items sold →</text>';
s += '<text transform="translate(16 ' + ((T + H - B) / 2) + ') rotate(-90)" text-anchor="middle" class="mx-ax">Gross profit per portion →</text>';
s += '<text x="' + tx + '" y="' + (H - B + 14) + '" text-anchor="middle" class="mx-tick">' + C.pct(me.threshold) + '</text>';
s += '<text x="' + (L - 4) + '" y="' + (ty + 4) + '" text-anchor="end" class="mx-tick">' + esc(money(me.avgCM)) + "</text>";
var boxes = [];
me.rows.slice().sort(function (a, b) { return b.cm - a.cm; }).forEach(function (r) {
var cx = x(r.mix), cy = y(r.cm), right = cx > W - 190, tw = Math.min(220, r.name.length * 7.6);
var lx0 = right ? cx - 11 - tw : cx + 11, ly = cy + 4;
for (var k = 0; k < 8 && boxes.some(function (b) { return lx0 < b.x1 && lx0 + tw > b.x0 && Math.abs(ly - b.y) < 16; }); k++) ly += 16;
boxes.push({ x0: lx0, x1: lx0 + tw, y: ly });
s += '<g class="mx-dot cls-' + r.cls + '"><title>' + esc(r.name + ": " + r.label + ", " + C.pct(r.mix) + " of items sold, " + money(r.cm) + " gross profit") + '</title><circle cx="' + cx.toFixed(1) + '" cy="' + cy.toFixed(1) + '" r="7"/>' +
'<text x="' + (cx + (right ? -11 : 11)).toFixed(1) + '" y="' + ly.toFixed(1) + '"' + (right ? ' text-anchor="end"' : "") + ">" + esc(r.name) + "</text></g>";
});
return s + "</svg>";
}
$("mx-table").addEventListener("input", function (e) {
if (!e.target.classList.contains("sold")) return;
var r = recById(e.target.getAttribute("data-id")); r.sold = C.parseNum(e.target.value); touch();
var id = e.target.id, pos = e.target.selectionStart;
renderMatrix();
var el = $(id); if (el) { el.focus(); try { el.setSelectionRange(pos, pos); } catch (er) { /* */ } }
});
$("mx-period").addEventListener("input", function () { S.period = this.value; save(); renderMatrix(); });
/* ---------- print cards ---------- */
function cardHTML(r) {
var s = C.summary(r, ctx(), S.settings), p = Number(r.portions) > 0 ? Number(r.portions) : 1;
var lines = s.lines.filter(function (l) { return l.ref; });
var d = new Date().toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
return '<article class="pcard"><header><div><h1>' + esc(r.name) + "</h1><p>" + esc((r.category ? r.category + " · " : "") + "Batch makes " + C.fmtQty(p) + (p === 1 ? " portion" : " portions")) + "</p></div><p class=\"pc-date\">Costed " + esc(d) + "</p></header>" +
'<table><thead><tr><th>Ingredient</th><th class="num">Qty</th><th>Unit</th><th class="num">Cost</th><th class="num">Share</th></tr></thead><tbody>' +
lines.map(function (l) { return "<tr><td>" + esc(l.name) + (l.sub ? " (sub-recipe)" : "") + '</td><td class="num">' + C.fmtQty(l.qty) + "</td><td>" + esc(l.unit) + '</td><td class="num">' + money(l.cost, l.cost < 0.1 && l.cost > 0 ? 3 : 2) + '</td><td class="num">' + C.pct(l.share * 100) + "</td></tr>"; }).join("") +
'</tbody><tfoot><tr><td colspan="3">Batch cost</td><td class="num">' + money(s.batch) + '</td><td></td></tr></tfoot></table>' +
'<div class="pc-nums"><div><span>Cost per portion</span><b>' + money(s.portion) + "</b></div><div><span>Menu price</span><b>" + (s.price > 0 ? money(s.price) : "-") + "</b></div><div><span>Food cost</span><b>" + (s.foodCost == null ? "-" : C.pct(s.foodCost)) + "</b></div><div><span>Price at " + C.fmtQty(S.settings.target) + "% target</span><b>" + (s.suggested > 0 ? money(s.suggested) : "-") + "</b></div><div><span>Gross profit</span><b>" + (s.margin == null ? "-" : money(s.margin)) + "</b></div></div>" +
(r.method ? '<div class="pc-method"><h2>Method / notes</h2><p>' + esc(r.method) + "</p></div>" : "") +
(S.settings.taxIncl ? '<p class="pc-small">Menu price includes ' + C.fmtQty(S.settings.taxIncl) + "% tax; food cost % uses the price without tax.</p>" : "") +
(pro() ? "" : '<p class="pc-credit">Costed with Platesprig by CyberMax · ' + HUB + "</p>") + "</article>";
}
function printCards(list) {
$("print-area").innerHTML = list.map(cardHTML).join("");
document.body.classList.add("printing");
setTimeout(function () { window.print(); }, 30);
}
window.addEventListener("beforeprint", function () {
if (document.body.classList.contains("printing")) return;
var r = recipe(); $("print-area").innerHTML = r ? cardHTML(r) : "";
});
window.addEventListener("afterprint", function () { document.body.classList.remove("printing"); });
$("btn-print").addEventListener("click", function () { var r = recipe(); if (r) printCards([r]); });
$("btn-print-all").addEventListener("click", function () {
if (!pro()) return upsell("Printing every cost card at once is part of Platesprig Pro.", "print-all");
printCards(S.recipes);
});
/* ---------- backup ---------- */
$("btn-backup").addEventListener("click", function () {
if (!pro()) return upsell("JSON backup is part of Platesprig Pro.", "backup");
X.download("platesprig-backup-" + today() + ".json", JSON.stringify({ app: "platesprig", version: 1, exported: new Date().toISOString(), data: S }, null, 1), "application/json");
toast("Backup downloaded.");
});
$("lbl-restore").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("Restoring a backup is part of Platesprig Pro.", "backup"); } });
$("f-restore").addEventListener("change", function () {
var f = this.files && this.files[0]; this.value = ""; if (!f) return;
f.text().then(function (t) {
var j; try { j = JSON.parse(t); } catch (e) { j = null; }
if (!j || j.app !== "platesprig" || !j.data || !Array.isArray(j.data.recipes)) { toast("That is not a Platesprig backup file."); return; }
S = j.data; var fr = fresh(); Object.keys(fr).forEach(function (k) { if (S[k] == null) S[k] = fr[k]; });
save(); settingsToForm(); renderAll(); toast("Backup restored: " + S.recipes.length + " recipes, " + S.ingredients.length + " ingredients.");
});
});
$("btn-clear").addEventListener("click", function () {
if (!confirm("Remove all recipes and ingredients from this browser?")) return;
var st = S.settings; S = fresh(); S.settings = st; S.dirty = true; save(); renderAll();
});
/* ---------- examples ---------- */
$("btn-example").addEventListener("click", function () {
if (S.dirty && (S.recipes.length || S.ingredients.length) && !confirm("Replace your recipes and ingredients with the example?")) return;
loadExample($("example-kind").value);
settingsToForm(); showTab("recipes"); renderAll();
toast("Example loaded: " + S.recipes.length + " recipes, " + S.ingredients.length + " ingredients.");
});
/* ---------- Pro chrome ---------- */
$("btn-cta").addEventListener("click", function () { upsell("", "mid-cta"); });
function renderAll() { renderList(); recipeToForm(); renderLines(); renderResult(); renderMenuTable(); renderIngredients(); renderImpact(); renderMatrix(); }
function renderRecipe() { renderList(); recipeToForm(); renderLines(); renderResult(); renderMenuTable(); }
X.onChange(function (on) {
$("pro-pill").textContent = on ? "Pro ✓" : "Pro";
$("pro-pill").title = on ? "Platesprig Pro is on" : "See Pro plans";
if ($("rec-list")) renderAll();
});
/* ---------- install + offline ---------- */
var deferred = null;
window.addEventListener("beforeinstallprompt", function (e) { e.preventDefault(); deferred = e; $("btn-install").hidden = false; });
$("btn-install").addEventListener("click", function () {
if (!deferred) return;
deferred.prompt(); deferred.userChoice.finally(function () { deferred = null; $("btn-install").hidden = true; });
});
window.addEventListener("appinstalled", function () { $("btn-install").hidden = true; });
if ("serviceWorker" in navigator && (location.protocol === "https:" || /^(localhost|127\.0\.0\.1)$/.test(location.hostname))) {
window.addEventListener("load", function () { navigator.serviceWorker.register("sw.js").catch(function () { /* offline cache is optional */ }); });
}
settingsToForm();
renderAll();
})();