Spaces:
Running
Running
Download platesprig/app.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 43 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/platesprig/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/platesprig/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/platesprig/app.js
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(); | |
| })(); | |