/* 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 '"; }).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 '"; }).join("") || '

No recipes yet. Add one, or load an example above.

'; 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 = '' + C.UNIT_LIST.map(function (u) { return ""; }).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 '' + S.ingredients.slice().sort(function (a, b) { return a.name.localeCompare(b.name); }).map(function (i) { return '"; }).join("") + ""; var subs = S.recipes.filter(function (x) { return x.id !== r.id && !C.wouldLoop(S, r.id, x.id); }); if (subs.length) opts += '' + subs.map(function (x) { return '"; }).join("") + ""; 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 "" + u + ""; }).join(""); } function renderLines() { var r = recipe(); if (!r) { $("lines").innerHTML = ""; return; } $("lines").innerHTML = (r.lines || []).map(function (l, i) { return '
' + '
" + '
' + '
" + '
' + '
' + '
'; }).join("") || '

No ingredients in this recipe yet.

'; 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 "
  • " + esc(e) + "
  • "; }).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 '
    ' + esc(l.name) + (l.sub ? ' sub-recipe' : "") + '' + money(l.cost / p, l.cost / p < 0.1 ? 3 : 2) + ' ' + C.pct(l.share * 100, 0) + "
    "; }).join("") || '

    Add ingredients to see what each one adds to the plate.

    '; $("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 "" + esc(r.name) + '' + money(s.portion) + '' + (s.price > 0 ? money(s.price) : "-") + '' + (s.foodCost == null ? "-" : C.pct(s.foodCost)) + '' + (s.suggested > 0 ? money(s.suggested) : "-") + '' + (s.margin == null ? "-" : money(s.margin)) + ""; }).join(""); $("menu-table").innerHTML = 'DishCost / portionMenu priceFood costPrice at ' + C.fmtQty(t) + '%Gross profit' + (rows || 'No recipes yet.') + ""; } 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 '
    ' + '
    ' + '
    ' + '
    " + '
    ' + '
    ' + '
    ' + '
    ' + esc(usableLabel(i)) + '' + (uses[i.id] ? "in " + uses[i.id] + (uses[i.id] === 1 ? " recipe" : " recipes") : "not used yet") + "
    " + '
    '; }).join("") || '

    ' + (q ? "No ingredient matches." : "No ingredients yet. Add what you buy, with the pack size and price.") + "

    "; 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 'No dish changes cost.'; return "" + esc(title) + 'DishPlate cost beforeAfterChangeFood cost nowPrice at ' + C.fmtQty(S.settings.target) + "%" + rows.map(function (r) { return "" + esc(r.name) + '' + money(r.before) + '' + money(r.after) + '' + (r.delta > 0 ? "+" : "") + money(r.delta) + (r.deltaPct != null ? " (" + (r.delta > 0 ? "+" : "") + C.pct(r.deltaPct) + ")" : "") + '' + (r.fcAfter == null ? "-" : C.pct(r.fcBefore) + " → " + C.pct(r.fcAfter)) + '' + (r.suggested > 0 ? money(r.suggested) : "-") + ""; }).join("") + ""; } 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 '"; }).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 = '

    Since your last price import

    ' + esc(li.file || "CSV") + ", " + esc(String(li.at).slice(0, 10)) + '

    ' + impactRows(rows, "Plate cost changes since the import") + "
    "; } 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 = 'DishSoldMixGross profitClassWhat to do' + priced.map(function (r) { var m = byId[r.id]; return "" + esc(r.name) + '' + C.pct(m.mix) + '' + money(m.cm) + '' + m.label + '' + esc(m.action) + ""; }).join("") + ""; if (!priced.length) { $("mx-table").innerHTML = "Give your dishes a menu price first."; $("mx-sum").textContent = ""; $("mx-chart").innerHTML = ""; return; } $("mx-sum").innerHTML = "" + me.totalSold + " items sold" + (S.period ? " in " + esc(S.period) : "") + " · popularity line " + C.pct(me.threshold) + " of the mix (70% × 100% ÷ " + me.n + ") · average gross profit " + money(me.avgCM) + " per portion · total gross profit " + money(me.totalCM) + "" + (me.foodCost != null ? " · menu food cost " + C.pct(me.foodCost) + "" : ""); $("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 = ''; s += ''; s += 'StarsPuzzles'; s += 'PlowhorsesDogs'; s += ''; s += 'Share of items sold →'; s += 'Gross profit per portion →'; s += '' + C.pct(me.threshold) + ''; s += '' + esc(money(me.avgCM)) + ""; 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 += '' + esc(r.name + ": " + r.label + ", " + C.pct(r.mix) + " of items sold, " + money(r.cm) + " gross profit") + '' + '" + esc(r.name) + ""; }); return s + ""; } $("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 '

    ' + esc(r.name) + "

    " + esc((r.category ? r.category + " · " : "") + "Batch makes " + C.fmtQty(p) + (p === 1 ? " portion" : " portions")) + "

    Costed " + esc(d) + "

    " + '' + lines.map(function (l) { return ""; }).join("") + '
    IngredientQtyUnitCostShare
    " + esc(l.name) + (l.sub ? " (sub-recipe)" : "") + '' + C.fmtQty(l.qty) + "" + esc(l.unit) + '' + money(l.cost, l.cost < 0.1 && l.cost > 0 ? 3 : 2) + '' + C.pct(l.share * 100) + "
    Batch cost' + money(s.batch) + '
    ' + '
    Cost per portion' + money(s.portion) + "
    Menu price" + (s.price > 0 ? money(s.price) : "-") + "
    Food cost" + (s.foodCost == null ? "-" : C.pct(s.foodCost)) + "
    Price at " + C.fmtQty(S.settings.target) + "% target" + (s.suggested > 0 ? money(s.suggested) : "-") + "
    Gross profit" + (s.margin == null ? "-" : money(s.margin)) + "
    " + (r.method ? '

    Method / notes

    ' + esc(r.method) + "

    " : "") + (S.settings.taxIncl ? '

    Menu price includes ' + C.fmtQty(S.settings.taxIncl) + "% tax; food cost % uses the price without tax.

    " : "") + (pro() ? "" : '

    Costed with Platesprig by CyberMax · ' + HUB + "

    ") + "
    "; } 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(); })();