Spaces:
Running
Running
Deploy hazelsprig to the hub
Browse files- .gitattributes +1 -0
- hazelsprig/app.js +455 -0
- hazelsprig/icon-192.png +0 -0
- hazelsprig/icon-512.png +0 -0
- hazelsprig/icon.svg +11 -0
- hazelsprig/index.html +173 -0
- hazelsprig/lib/content.js +221 -0
- hazelsprig/lib/core.js +223 -0
- hazelsprig/manifest.webmanifest +17 -0
- hazelsprig/og.png +3 -0
- hazelsprig/pro.js +164 -0
- hazelsprig/robots.txt +3 -0
- hazelsprig/sitemap.xml +2 -0
- hazelsprig/style.css +349 -0
- hazelsprig/sw.js +34 -0
.gitattributes
CHANGED
|
@@ -1133,3 +1133,4 @@ brand/hazelsprig/image-3-output.png filter=lfs diff=lfs merge=lfs -text
|
|
| 1133 |
brand/hazelsprig/og.png filter=lfs diff=lfs merge=lfs -text
|
| 1134 |
brand/hazelsprig/image-1-shot.png filter=lfs diff=lfs merge=lfs -text
|
| 1135 |
brand/hazelsprig/image-2-shot.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 1133 |
brand/hazelsprig/og.png filter=lfs diff=lfs merge=lfs -text
|
| 1134 |
brand/hazelsprig/image-1-shot.png filter=lfs diff=lfs merge=lfs -text
|
| 1135 |
brand/hazelsprig/image-2-shot.png filter=lfs diff=lfs merge=lfs -text
|
| 1136 |
+
hazelsprig/og.png filter=lfs diff=lfs merge=lfs -text
|
hazelsprig/app.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Hazelsprig app: HACCP plan builder, complaint register, CAPA log, audits, trends and printable binder.
|
| 2 |
+
* Everything is stored in this browser (localStorage). (c) 2026 CyberMax. All rights reserved. */
|
| 3 |
+
(function () {
|
| 4 |
+
"use strict";
|
| 5 |
+
var HZ = window.HZ, HZC = window.HZC, P = window.CMXPro;
|
| 6 |
+
var KEY = "hazelsprig:v1";
|
| 7 |
+
var $ = function (s, r) { return (r || document).querySelector(s); };
|
| 8 |
+
var esc = function (s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); };
|
| 9 |
+
var pro = function () { return !!(P && P.isPro()); };
|
| 10 |
+
|
| 11 |
+
// ---------- state ----------
|
| 12 |
+
function blank() { return { v: 1, biz: { name: "", site: "", lead: "", reply: "2" }, plans: [], planId: null, complaints: [], capas: [], audits: [], volumes: {}, ui: { tab: "plan", cId: null, aId: null, run: null } }; }
|
| 13 |
+
var S;
|
| 14 |
+
try { S = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { S = null; }
|
| 15 |
+
if (!S || S.v !== 1) S = blank();
|
| 16 |
+
S.ui = S.ui || { tab: "plan" };
|
| 17 |
+
var saveT;
|
| 18 |
+
function saveNow() { clearTimeout(saveT); saveT = null; try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { toast("Could not save: browser storage is full or blocked."); } usage(); }
|
| 19 |
+
// Typing is saved shortly after the last keystroke; every other change is saved at once.
|
| 20 |
+
function save(soon) { if (!soon) return saveNow(); clearTimeout(saveT); saveT = setTimeout(saveNow, 300); }
|
| 21 |
+
window.addEventListener("pagehide", function () { if (saveT) saveNow(); });
|
| 22 |
+
document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden" && saveT) saveNow(); });
|
| 23 |
+
function toast(m) { if (P) P.toast(m); }
|
| 24 |
+
function plan() { var p = S.plans.filter(function (x) { return x.id === S.planId; })[0]; if (!p && S.plans.length) { p = S.plans[0]; S.planId = p.id; } return p || null; }
|
| 25 |
+
function find(list, id) { for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i]; return null; }
|
| 26 |
+
function seq(list, prefix) { var n = 0; list.forEach(function (x) { var m = new RegExp("^" + prefix + "-(\\d+)$").exec(x.ref || ""); if (m) n = Math.max(n, +m[1]); }); return n + 1; }
|
| 27 |
+
function limitHit(kind, msg) { if (P) P.upsell(msg, "limit-" + kind); }
|
| 28 |
+
|
| 29 |
+
// ---------- generic binding: data-b="kind:id:field[:index]" ----------
|
| 30 |
+
function target(b) {
|
| 31 |
+
var p = b.split(":"), kind = p[0], id = p[1], f = p[2], pl = plan();
|
| 32 |
+
if (kind === "biz") return [S.biz, id];
|
| 33 |
+
if (kind === "plan") return pl ? [pl, id] : null;
|
| 34 |
+
if (kind === "step") { var st = pl && find(pl.steps, id); return st ? [st, f] : null; }
|
| 35 |
+
if (kind === "hz") { var h = null; (pl ? pl.steps : []).forEach(function (s) { h = h || find(s.hazards, id); }); return h ? [h, f] : null; }
|
| 36 |
+
if (kind === "c") { var c = find(S.complaints, id); if (!c) return null; if (f === "whys") return [c.whys, +p[3]]; return [c, f]; }
|
| 37 |
+
if (kind === "a") { var a = find(S.capas, id); return a ? [a, f] : null; }
|
| 38 |
+
if (kind === "vol") return [S.volumes, id];
|
| 39 |
+
if (kind === "run") { var r = S.ui.run; if (!r) return null; r.answers[id] = r.answers[id] || {}; return [r.answers[id], f]; }
|
| 40 |
+
return null;
|
| 41 |
+
}
|
| 42 |
+
document.addEventListener("input", function (e) {
|
| 43 |
+
var b = e.target.getAttribute && e.target.getAttribute("data-b"); if (!b || e.target.tagName === "SELECT") return;
|
| 44 |
+
var t = target(b); if (!t) return; t[0][t[1]] = e.target.value;
|
| 45 |
+
if (b.indexOf("plan:name") === 0) { var chip = $(".rec.on b"); if (chip) chip.textContent = e.target.value || "Untitled plan"; }
|
| 46 |
+
save(true);
|
| 47 |
+
});
|
| 48 |
+
document.addEventListener("change", function (e) {
|
| 49 |
+
var b = e.target.getAttribute && e.target.getAttribute("data-b"); if (!b) return;
|
| 50 |
+
var t = target(b); if (!t) return; t[0][t[1]] = e.target.value;
|
| 51 |
+
// Text and date fields are saved on input; re-rendering on their change event would replace the field the user
|
| 52 |
+
// just moved to. Only selects (which change what is shown) re-render.
|
| 53 |
+
if (e.target.tagName !== "SELECT") { save(); return; }
|
| 54 |
+
if (/^c:[^:]+:severity$/.test(b)) find(S.complaints, b.split(":")[1]).severityTouched = true;
|
| 55 |
+
if (/^c:[^:]+:status$/.test(b)) { var c = find(S.complaints, b.split(":")[1]); if (c.status === "Closed" && !c.closed) c.closed = HZ.today(); }
|
| 56 |
+
if (/^a:[^:]+:status$/.test(b)) { var a = find(S.capas, b.split(":")[1]); if (a.status === "Closed") { if (!HZ.canClose(a)) { a.status = "Verify"; toast("Record the corrective action, the verification date and whether it worked before closing this CAPA."); } else if (!a.closed) a.closed = HZ.today(); } }
|
| 57 |
+
save(); render();
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
// ---------- tabs ----------
|
| 61 |
+
var TABS = ["plan", "complaints", "capa", "audits", "trends", "forms"];
|
| 62 |
+
function setTab(t) {
|
| 63 |
+
S.ui.tab = t;
|
| 64 |
+
TABS.forEach(function (x) { $("#tab-" + x).setAttribute("aria-selected", x === t ? "true" : "false"); $("#p-" + x).hidden = x !== t; });
|
| 65 |
+
render(); save();
|
| 66 |
+
}
|
| 67 |
+
TABS.forEach(function (x) { $("#tab-" + x).addEventListener("click", function () { setTab(x); }); });
|
| 68 |
+
|
| 69 |
+
function render() {
|
| 70 |
+
var t = S.ui.tab;
|
| 71 |
+
({ plan: rPlan, complaints: rComplaints, capa: rCapa, audits: rAudits, trends: rTrends, forms: rForms })[t]();
|
| 72 |
+
usage();
|
| 73 |
+
}
|
| 74 |
+
function usage() {
|
| 75 |
+
var el = $("#usage"); if (!el) return;
|
| 76 |
+
if (pro()) { el.textContent = "Pro: no limits. " + S.complaints.length + " complaints · " + S.capas.length + " CAPAs · " + S.audits.length + " audits saved on this device."; el.classList.remove("out"); return; }
|
| 77 |
+
var F = HZ.FREE, out = S.plans.length >= F.plans || S.complaints.length >= F.complaints || S.capas.length >= F.capas;
|
| 78 |
+
el.textContent = "Free: " + S.plans.length + "/" + F.plans + " plan · " + S.complaints.length + "/" + F.complaints + " complaints · " + S.capas.length + "/" + F.capas + " CAPAs · saved on this device";
|
| 79 |
+
el.classList.toggle("out", out);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ---------- small UI helpers ----------
|
| 83 |
+
function inp(b, val, ph, extra) { return '<input data-b="' + b + '" value="' + esc(val) + '"' + (ph ? ' placeholder="' + esc(ph) + '"' : "") + (extra || "") + ">"; }
|
| 84 |
+
function area(b, val, ph, rows) { return '<textarea data-b="' + b + '" rows="' + (rows || 2) + '"' + (ph ? ' placeholder="' + esc(ph) + '"' : "") + ">" + esc(val) + "</textarea>"; }
|
| 85 |
+
function sel(b, val, opts) { return '<select data-b="' + b + '">' + opts.map(function (o) { var v = typeof o === "string" ? o : o[0], l = typeof o === "string" ? o : o[1]; return '<option value="' + esc(v) + '"' + (String(val) === String(v) ? " selected" : "") + ">" + esc(l) + "</option>"; }).join("") + "</select>"; }
|
| 86 |
+
function fld(label, html, cls) { return '<div' + (cls ? ' class="' + cls + '"' : "") + "><label>" + label + "</label>" + html + "</div>"; }
|
| 87 |
+
var YN = [["", "Choose"], ["yes", "Yes"], ["no", "No"]];
|
| 88 |
+
|
| 89 |
+
// ---------- HACCP plan ----------
|
| 90 |
+
function rPlan() {
|
| 91 |
+
var el = $("#p-plan"), p = plan();
|
| 92 |
+
var chips = S.plans.map(function (x) { var n = HZ.ccps(x).length; return '<button type="button" class="rec' + (p && x.id === p.id ? " on" : "") + '" data-act="pick-plan" data-id="' + x.id + '"><b>' + esc(x.name || "Untitled plan") + "</b><span>" + x.steps.length + " steps · " + n + " CCP" + (n === 1 ? "" : "s") + "</span></button>"; }).join("");
|
| 93 |
+
var head = '<div class="card"><div class="picker-head"><h2>HACCP plans</h2><div class="btns"><button class="btn sm" type="button" data-act="new-plan">New plan</button>' +
|
| 94 |
+
(p ? '<button class="btn ghost sm" type="button" data-act="print-plan">Print plan</button><button class="btn ghost sm danger" type="button" data-act="del-plan">Delete plan</button>' : "") + "</div></div>" +
|
| 95 |
+
(chips ? '<div class="rec-list">' + chips + "</div>" : '<p class="hint">No plan yet. Start a new plan or load an example above.</p>') + "</div>";
|
| 96 |
+
if (!p) { el.innerHTML = head; return; }
|
| 97 |
+
var desc = '<div class="card"><h2>1. Product and scope</h2><div class="g2">' +
|
| 98 |
+
fld("Plan name", inp("plan:name", p.name)) + fld("HACCP team (roles)", inp("plan:team", p.team, "e.g. Owner, head chef")) +
|
| 99 |
+
fld("Product description", area("plan:product", p.product, "What it is, main ingredients, packaging"), "span2") +
|
| 100 |
+
fld("Intended use and consumers", area("plan:use", p.use, "How it is eaten and by whom, incl. vulnerable groups"), "span2") +
|
| 101 |
+
fld("Storage and shelf life", inp("plan:storage", p.storage, "e.g. chilled at 41°F, sold same day")) + fld("Scope", inp("plan:scope", p.scope, "From receiving to service")) + "</div></div>";
|
| 102 |
+
var steps = p.steps.map(function (s, i) {
|
| 103 |
+
var hz = s.hazards.map(function (h) { return hazardCard(h); }).join("");
|
| 104 |
+
return '<div class="card step"><div class="picker-head"><h3>Step ' + (i + 1) + "</h3><div class=btns>" +
|
| 105 |
+
'<button class="icon-btn" type="button" data-act="step-up" data-id="' + s.id + '" aria-label="Move step up"' + (i ? "" : " disabled") + ">↑</button>" +
|
| 106 |
+
'<button class="icon-btn" type="button" data-act="step-down" data-id="' + s.id + '" aria-label="Move step down"' + (i < p.steps.length - 1 ? "" : " disabled") + ">↓</button>" +
|
| 107 |
+
'<button class="icon-btn" type="button" data-act="del-step" data-id="' + s.id + '" aria-label="Delete step">×</button></div></div>' +
|
| 108 |
+
inp("step:" + s.id + ":name", s.name, "Step name, e.g. Receive, Store, Cook, Cool, Serve", ' aria-label="Step name"') + hz +
|
| 109 |
+
'<div class="btns"><button class="btn ghost sm" type="button" data-act="add-hz" data-id="' + s.id + '">+ Add hazard</button></div></div>';
|
| 110 |
+
}).join("");
|
| 111 |
+
var ccp = HZ.ccps(p), gaps = HZ.planGaps(p);
|
| 112 |
+
var sum = '<div class="card"><h2>3. CCP summary</h2>' + (ccp.length ? '<div class="table-wrap"><table class="ht"><thead><tr><th>CCP</th><th>Step</th><th>Hazard</th><th>Critical limit</th><th>Monitoring</th><th>Corrective action</th></tr></thead><tbody>' +
|
| 113 |
+
ccp.map(function (c) { return "<tr><td><b>" + c.label + "</b></td><td>" + esc(c.step.name) + "</td><td>" + esc(c.hazard.hazard) + '</td><td class="act">' + esc(c.hazard.limit) + '</td><td class="act">' + esc([c.hazard.monitor, c.hazard.freq].filter(Boolean).join(", ")) + '</td><td class="act">' + esc(c.hazard.correct) + "</td></tr>"; }).join("") +
|
| 114 |
+
"</tbody></table></div>" : '<p class="hint">No CCPs yet. Mark hazards as significant and answer the four questions.</p>') +
|
| 115 |
+
'<h3>Still to do</h3>' + (gaps.length ? '<ul class="todo">' + gaps.slice(0, 12).map(function (g) { return "<li>" + esc(g) + "</li>"; }).join("") + (gaps.length > 12 ? "<li>…and " + (gaps.length - 12) + " more</li>" : "") + "</ul>" : '<p class="ok-line">Every section has an entry. Review it with your food safety lead and sign it off.</p>') + "</div>";
|
| 116 |
+
el.innerHTML = head + desc + '<div class="card"><h2>2. Process steps and hazard analysis</h2><p class="hint">List each step in order. For each hazard: is it significant (likely and severe)? If yes, answer the CCP questions.</p></div>' + steps +
|
| 117 |
+
'<div class="btns"><button class="btn sm" type="button" data-act="add-step">+ Add step</button></div>' + sum;
|
| 118 |
+
}
|
| 119 |
+
function hazardCard(h) {
|
| 120 |
+
var d = HZ.ccpDecision(h), sig = h.significant === "yes";
|
| 121 |
+
var qs = sig ? '<div class="qs">' +
|
| 122 |
+
q("Q1", "Do control measures exist for this hazard at this step?", "q1", h) +
|
| 123 |
+
(h.q1 === "yes" ? q("Q2", "Is this step designed to eliminate the hazard or reduce it to an acceptable level?", "q2", h) : "") +
|
| 124 |
+
(h.q1 === "yes" && h.q2 === "no" ? q("Q3", "Could contamination occur, or increase to unacceptable levels, at this step?", "q3", h) : "") +
|
| 125 |
+
(h.q1 === "yes" && h.q2 === "no" && h.q3 === "yes" ? q("Q4", "Will a later step eliminate the hazard or reduce it to an acceptable level?", "q4", h) : "") +
|
| 126 |
+
'<p class="verdict ' + (d.ccp ? "is-ccp" : d.done ? "not-ccp" : "") + '">' + esc(d.note) + "</p></div>" : "";
|
| 127 |
+
var ccpf = sig && d.ccp ? '<div class="g2 ccp-f">' + fld("Critical limit", inp("hz:" + h.id + ":limit", h.limit, "e.g. 165°F (74°C) internal")) + fld("Monitoring: what and how", inp("hz:" + h.id + ":monitor", h.monitor, "e.g. probe thermometer")) +
|
| 128 |
+
fld("Frequency", inp("hz:" + h.id + ":freq", h.freq, "e.g. every batch")) + fld("Who", inp("hz:" + h.id + ":who", h.who, "Role")) +
|
| 129 |
+
fld("Corrective action", area("hz:" + h.id + ":correct", h.correct, "What happens when the limit is missed"), "span2") +
|
| 130 |
+
fld("Verification", inp("hz:" + h.id + ":verify", h.verify, "e.g. weekly log review, calibration")) + fld("Records", inp("hz:" + h.id + ":records", h.records, "e.g. cooking log")) + "</div>" : "";
|
| 131 |
+
return '<div class="hz"><div class="hz-top"><span class="hz-type t-' + esc(h.type) + '">' + esc(h.type) + "</span>" + (sig && d.ccp ? '<span class="ccp-badge">CCP</span>' : "") +
|
| 132 |
+
'<button class="icon-btn" type="button" data-act="del-hz" data-id="' + h.id + '" aria-label="Delete hazard">×</button></div><div class="g3">' +
|
| 133 |
+
fld("Type", sel("hz:" + h.id + ":type", h.type, HZ.HAZARD_TYPES.map(function (t) { return [t.id, t.label]; }))) + fld("Hazard", inp("hz:" + h.id + ":hazard", h.hazard, "e.g. Salmonella survival")) + fld("Cause / source", inp("hz:" + h.id + ":cause", h.cause, "e.g. undercooking")) +
|
| 134 |
+
fld("Control measure", inp("hz:" + h.id + ":control", h.control, "e.g. cook to temperature")) + fld("Significant?", sel("hz:" + h.id + ":significant", h.significant, [["", "Choose"], ["yes", "Yes: likely and severe"], ["no", "No"]])) + "</div>" + qs + ccpf + "</div>";
|
| 135 |
+
}
|
| 136 |
+
function q(n, text, f, h) { return '<div class="q"><span><b>' + n + "</b> " + esc(text) + "</span>" + sel("hz:" + h.id + ":" + f, h[f], YN) + "</div>"; }
|
| 137 |
+
|
| 138 |
+
// ---------- complaints ----------
|
| 139 |
+
function rComplaints() {
|
| 140 |
+
var el = $("#p-complaints"), cur = find(S.complaints, S.ui.cId);
|
| 141 |
+
var rows = S.complaints.slice().sort(function (a, b) { return (b.date || "").localeCompare(a.date || "") || (b.ref || "").localeCompare(a.ref || ""); }).map(function (c) {
|
| 142 |
+
var sev = c.severity || HZ.suggestSeverity(c);
|
| 143 |
+
return '<tr class="pick' + (cur && cur.id === c.id ? " on" : "") + '" data-act="pick-c" data-id="' + c.id + '"><td><b>' + esc(c.ref) + "</b></td><td>" + esc(c.date) + "</td><td>" + esc(c.product || "-") + "</td><td>" + esc(HZ.catLabel(c.category)) +
|
| 144 |
+
'</td><td><span class="sev s-' + sev + '">' + sev + '</span></td><td>' + esc(c.status) + "</td></tr>";
|
| 145 |
+
}).join("");
|
| 146 |
+
var list = '<div class="card"><div class="picker-head"><h2>Customer complaint register</h2><div class="btns"><button class="btn sm" type="button" data-act="new-c">New complaint</button>' +
|
| 147 |
+
'<button class="btn ghost sm" type="button" data-act="print-reg">Print register</button><button class="btn ghost sm" type="button" data-act="print-proc">Print complaint procedure</button>' +
|
| 148 |
+
'<button class="btn ghost sm" type="button" data-act="csv-c">CSV <span class="pro-tag">PRO</span></button></div></div>' +
|
| 149 |
+
(rows ? '<div class="table-wrap"><table class="ht"><thead><tr><th>Ref</th><th>Date</th><th>Product</th><th>Category</th><th>Severity</th><th>Status</th></tr></thead><tbody>' + rows + "</tbody></table></div>" : '<p class="hint">No complaints logged yet. Every complaint, however small, belongs here: trends start with small ones.</p>') + "</div>";
|
| 150 |
+
el.innerHTML = list + (cur ? complaintForm(cur) : "");
|
| 151 |
+
}
|
| 152 |
+
function complaintForm(c) {
|
| 153 |
+
var b = "c:" + c.id + ":", sev = c.severity || HZ.suggestSeverity(c), esc8 = HZ.escalation(c);
|
| 154 |
+
var capa = c.capa ? find(S.capas, c.capa) : null;
|
| 155 |
+
var whys = c.whys.map(function (w, i) { return fld("Why " + (i + 1) + "?", inp(b + "whys:" + i, w, i ? "" : "Why did this happen?")); }).join("");
|
| 156 |
+
return '<div class="card"><div class="picker-head"><h2>' + esc(c.ref) + '</h2><div class="btns"><button class="btn ghost sm danger" type="button" data-act="del-c" data-id="' + c.id + '">Delete</button></div></div>' +
|
| 157 |
+
(esc8 ? '<p class="alert" role="alert">' + esc(esc8) + "</p>" : "") +
|
| 158 |
+
'<div class="g3">' + fld("Date received", inp(b + "date", c.date, "", ' type="date"')) + fld("Channel", sel(b + "channel", c.channel, ["In person", "Phone", "Email", "Online review", "Social media", "Retailer / distributor", "Regulator", "Other"])) +
|
| 159 |
+
fld("Customer (name or initials)", inp(b + "customer", c.customer, "Optional")) +
|
| 160 |
+
fld("Product", inp(b + "product", c.product, "e.g. Chicken plate")) + fld("Batch / lot / date code", inp(b + "lot", c.lot)) +
|
| 161 |
+
fld("Category", sel(b + "category", c.category, HZ.COMPLAINT_CATS.map(function (x) { return [x.id, x.label]; }))) +
|
| 162 |
+
fld("What the customer reported", area(b + "desc", c.desc, "Their words, what, when, where bought", 3), "span2") +
|
| 163 |
+
fld("Severity (suggested: " + sev + ")", sel(b + "severity", c.severity, [["", "Use suggestion (" + sev + ")"], "Critical", "Major", "Minor"])) +
|
| 164 |
+
fld("Sample / evidence kept", inp(b + "sample", c.sample, "e.g. photo, packaging kept")) + fld("Contact for reply", inp(b + "contact", c.contact, "Optional; kept on this device")) +
|
| 165 |
+
fld("Investigation", area(b + "investigation", c.investigation, "Records checked, staff spoken to, retained samples", 2), "span2") + "</div>" +
|
| 166 |
+
'<h3>Root cause</h3><div class="g3 whys">' + whys + fld("Cause category", sel(b + "causeCat", c.causeCat, [["", "Choose"]].concat(HZ.CAUSE_CATS))) + "</div>" +
|
| 167 |
+
fld("Root cause (one line)", inp(b + "rootCause", c.rootCause, "The cause you will fix")) +
|
| 168 |
+
'<div class="g3">' + fld("Immediate action", inp(b + "action", c.action, "e.g. product held, refund given")) + fld("Reply to customer", inp(b + "response", c.response, "What you told them")) +
|
| 169 |
+
fld("Replied on", inp(b + "responded", c.responded, "", ' type="date"')) + fld("Status", sel(b + "status", c.status, ["Open", "Investigating", "Closed"])) + fld("Closed on", inp(b + "closed", c.closed, "", ' type="date"')) + "</div>" +
|
| 170 |
+
'<div class="btns">' + (capa ? '<button class="btn ghost sm" type="button" data-act="goto-capa" data-id="' + capa.id + '">Open ' + esc(capa.ref) + "</button>" : '<button class="btn sm" type="button" data-act="c-to-capa" data-id="' + c.id + '">Create CAPA from this complaint</button>') + "</div></div>";
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// ---------- CAPA ----------
|
| 174 |
+
function rCapa() {
|
| 175 |
+
var el = $("#p-capa"), cur = find(S.capas, S.ui.aId), now = HZ.today();
|
| 176 |
+
var rows = S.capas.slice().sort(function (a, b) { return (a.status === "Closed") - (b.status === "Closed") || (a.due || "9").localeCompare(b.due || "9"); }).map(function (a) {
|
| 177 |
+
var st = HZ.capaState(a, now);
|
| 178 |
+
return '<tr class="pick' + (cur && cur.id === a.id ? " on" : "") + '" data-act="pick-a" data-id="' + a.id + '"><td><b>' + esc(a.ref) + "</b></td><td>" + esc(a.source) + '</td><td class="act">' + esc(a.problem || "-") + "</td><td>" + esc(a.owner || "-") + "</td><td>" + esc(a.due || "-") +
|
| 179 |
+
'</td><td><span class="st st-' + st + '">' + ({ overdue: "Overdue", "due-soon": "Due soon", open: a.status, closed: "Closed" })[st] + "</span></td></tr>";
|
| 180 |
+
}).join("");
|
| 181 |
+
var over = S.capas.filter(function (a) { return HZ.capaState(a, now) === "overdue"; }).length;
|
| 182 |
+
el.innerHTML = '<div class="card"><div class="picker-head"><h2>CAPA log <span class="hint">corrective and preventive actions</span></h2><div class="btns"><button class="btn sm" type="button" data-act="new-a">New CAPA</button>' +
|
| 183 |
+
'<button class="btn ghost sm" type="button" data-act="print-capa">Print CAPA log</button><button class="btn ghost sm" type="button" data-act="csv-a">CSV <span class="pro-tag">PRO</span></button></div></div>' +
|
| 184 |
+
(over ? '<p class="alert">' + over + " CAPA" + (over === 1 ? " is" : "s are") + " overdue.</p>" : "") +
|
| 185 |
+
(rows ? '<div class="table-wrap"><table class="ht"><thead><tr><th>Ref</th><th>Source</th><th>Problem</th><th>Owner</th><th>Due</th><th>Status</th></tr></thead><tbody>' + rows + "</tbody></table></div>" : '<p class="hint">No CAPAs yet. Create one from a complaint or an audit finding, or start one here.</p>') + "</div>" + (cur ? capaForm(cur) : "");
|
| 186 |
+
}
|
| 187 |
+
function capaForm(a) {
|
| 188 |
+
var b = "a:" + a.id + ":";
|
| 189 |
+
return '<div class="card"><div class="picker-head"><h2>' + esc(a.ref) + '</h2><div class="btns"><button class="btn ghost sm danger" type="button" data-act="del-a" data-id="' + a.id + '">Delete</button></div></div><div class="g3">' +
|
| 190 |
+
fld("Opened", inp(b + "opened", a.opened, "", ' type="date"')) + fld("Source", sel(b + "source", a.source, HZ.CAPA_SOURCES)) + fld("Linked record", inp(b + "link", a.link, "e.g. C-0003 or audit date")) +
|
| 191 |
+
fld("Problem", area(b + "problem", a.problem, "What went wrong", 2), "span2") + fld("Owner (role)", inp(b + "owner", a.owner)) +
|
| 192 |
+
fld("Root cause", area(b + "rootCause", a.rootCause, "", 2), "span2") + fld("Due date", inp(b + "due", a.due, "", ' type="date"')) +
|
| 193 |
+
fld("Correction (immediate fix)", area(b + "correction", a.correction, "", 2)) + fld("Corrective action (removes the cause)", area(b + "corrective", a.corrective, "", 2)) + fld("Preventive action (stops it elsewhere)", area(b + "preventive", a.preventive, "", 2)) +
|
| 194 |
+
fld("Effectiveness checked on", inp(b + "verifyDate", a.verifyDate, "", ' type="date"')) + fld("Did it work? (evidence)", inp(b + "effective", a.effective, "e.g. no repeat in 30 days of checks")) + fld("Status", sel(b + "status", a.status, HZ.CAPA_STATUS)) +
|
| 195 |
+
"</div>" + (HZ.canClose(a) ? "" : '<p class="hint">Closing needs the corrective action, the effectiveness date and the evidence that it worked.</p>') + "</div>";
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// ---------- audits ----------
|
| 199 |
+
function rAudits() {
|
| 200 |
+
var el = $("#p-audits"), r = S.ui.run;
|
| 201 |
+
var cards = HZC.CHECKLISTS.map(function (c) {
|
| 202 |
+
var locked = !c.free && !pro(), last = S.audits.filter(function (a) { return a.list === c.id; }).slice(-1)[0];
|
| 203 |
+
return '<div class="tier' + (locked ? " locked-tier" : "") + '"><h3>' + esc(c.name) + (c.free ? "" : ' <span class="pro-tag">PRO</span>') + '</h3><p class="hint">' + c.items.length + " checks" + (last ? " · last " + esc(last.date) + " (" + (last.pct == null ? "-" : last.pct + "%") + ")" : "") + "</p>" +
|
| 204 |
+
'<button class="btn sm" type="button" data-act="start-audit" data-id="' + c.id + '">' + (locked ? "Unlock with Pro" : "Start audit") + "</button></div>";
|
| 205 |
+
}).join("");
|
| 206 |
+
var hist = S.audits.slice().reverse().map(function (a, i) { var c = listById(a.list); return '<tr><td>' + esc(a.date) + "</td><td>" + esc(c ? c.name : a.list) + "</td><td>" + (a.pct == null ? "-" : a.pct + "%") + "</td><td>" + a.findings.length + '</td><td><button class="linkbtn" type="button" data-act="print-audit" data-id="' + a.id + '">Print</button></td></tr>'; }).join("");
|
| 207 |
+
var run = "";
|
| 208 |
+
if (r) {
|
| 209 |
+
var c = listById(r.list), sc = HZ.auditScore(c.items, r.answers);
|
| 210 |
+
run = '<div class="card"><div class="picker-head"><h2>' + esc(c.name) + " · " + esc(r.date) + '</h2><span class="score">' + (sc.pct == null ? "-" : sc.pct + "%") + " · " + sc.no + " finding" + (sc.no === 1 ? "" : "s") + " · " + sc.open + " to answer</span></div>" +
|
| 211 |
+
c.items.map(function (it, i) { var a = r.answers[i] || {}; return '<div class="chk"><span>' + (i + 1) + ". " + esc(it) + '</span><div class="seg" role="group" aria-label="Answer">' + ["yes", "no", "na"].map(function (v) { return '<button type="button" class="' + (a.v === v ? "on v-" + v : "") + '" data-act="ans" data-id="' + i + '" data-v="' + v + '">' + ({ yes: "Yes", no: "No", na: "N/A" })[v] + "</button>"; }).join("") + "</div>" +
|
| 212 |
+
(a.v === "no" ? inp("run:" + i + ":note", a.note, "What did you find?", ' class="chk-note"') : "") + "</div>"; }).join("") +
|
| 213 |
+
'<div class="btns"><button class="btn" type="button" data-act="save-audit">Save audit</button><button class="btn ghost" type="button" data-act="cancel-audit">Cancel</button></div></div>';
|
| 214 |
+
}
|
| 215 |
+
el.innerHTML = '<div class="card"><h2>Audit checklists</h2><p class="hint">Walk the site, answer each check, and turn every "No" into a CAPA in one tap. Starter checklists: adapt them to your rules.</p><div class="tiers">' + cards + "</div></div>" + run +
|
| 216 |
+
(hist ? '<div class="card"><h2>Saved audits</h2><div class="table-wrap"><table class="ht"><thead><tr><th>Date</th><th>Checklist</th><th>Score</th><th>Findings</th><th></th></tr></thead><tbody>' + hist + "</tbody></table></div></div>" : "");
|
| 217 |
+
}
|
| 218 |
+
function listById(id) { return HZC.CHECKLISTS.filter(function (c) { return c.id === id; })[0]; }
|
| 219 |
+
|
| 220 |
+
// ---------- trends ----------
|
| 221 |
+
function rTrends() {
|
| 222 |
+
var el = $("#p-trends"), t = HZ.trends(S.complaints, { volumes: S.volumes });
|
| 223 |
+
var kpi = '<div class="tiles">' + tile("Complaints logged", t.total) + tile("Open now", t.open.length) + tile("Avg days to close", t.avgClose == null ? "-" : t.avgClose) +
|
| 224 |
+
tile("Last 3 months", t.last3 + (t.direction === "up" ? " ▲" : t.direction === "down" ? " ▼" : ""), "vs " + t.prev3 + " the 3 before") + "</div>";
|
| 225 |
+
if (!pro()) {
|
| 226 |
+
el.innerHTML = '<div class="card"><h2>Complaint trends</h2>' + kpi + '<div class="locked"><p><b>The trend report is Pro.</b> See complaints by month, a Pareto of categories, the products and root causes behind them, complaints per 10,000 units and the open-complaint ageing list, and print it for your management review.</p><button class="btn" type="button" data-act="upsell-trends">Unlock trends</button></div></div>';
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
+
var max = Math.max(1, Math.max.apply(null, t.months.map(function (m) { return m.count; })));
|
| 230 |
+
var bars = '<div class="mbars">' + t.months.map(function (m) { return '<div class="mb"><i style="height:' + Math.round(m.count / max * 100) + '%"></i><b>' + m.count + "</b><span>" + m.month.slice(5) + "</span></div>"; }).join("") + "</div>";
|
| 231 |
+
var par = t.pareto.map(function (r) { return '<div class="bar"><span class="b-name">' + esc(r.label) + '</span><span class="b-track"><i style="width:' + r.share + '%"></i></span><span class="b-val">' + r.count + " <small>" + r.share + "% · cum " + r.cum + "%</small></span></div>"; }).join("");
|
| 232 |
+
var prods = t.products.slice(0, 8).map(function (r) { return "<tr><td>" + esc(r.product) + '</td><td class="num">' + r.count + "</td></tr>"; }).join("");
|
| 233 |
+
var causes = Object.keys(t.causes).map(function (k) { return "<tr><td>" + esc(k) + '</td><td class="num">' + t.causes[k] + "</td></tr>"; }).join("");
|
| 234 |
+
var vol = t.months.slice(-6).map(function (m) { return "<tr><td>" + m.month + '</td><td class="num">' + m.count + '</td><td><input class="sold" inputmode="numeric" data-b="vol:' + m.month + '" value="' + esc(S.volumes[m.month] || "") + '" aria-label="Units in ' + m.month + '"></td><td class="num">' + (m.per10k == null ? "-" : m.per10k) + "</td></tr>"; }).join("");
|
| 235 |
+
var open = t.open.slice(0, 10).map(function (o) { return "<tr><td>" + esc(o.ref) + "</td><td>" + esc(HZ.catLabel(o.category)) + '</td><td><span class="sev s-' + o.severity + '">' + o.severity + '</span></td><td class="num">' + (o.age == null ? "-" : o.age) + "</td></tr>"; }).join("");
|
| 236 |
+
el.innerHTML = '<div class="card"><div class="picker-head"><h2>Complaint trends</h2><div class="btns"><button class="btn ghost sm" type="button" data-act="print-trends">Print trend report</button></div></div>' + kpi +
|
| 237 |
+
'<h3>Complaints per month (last 12)</h3>' + bars + '<h3>By category (Pareto)</h3><div class="bars">' + (par || '<p class="hint">No data yet.</p>') + "</div>" +
|
| 238 |
+
'<div class="cols2"><div><h3>By product</h3><table class="ht"><tbody>' + (prods || "<tr><td>No data yet</td></tr>") + '</tbody></table></div><div><h3>By root-cause category</h3><table class="ht"><tbody>' + (causes || "<tr><td>None recorded yet</td></tr>") + "</tbody></table></div></div>" +
|
| 239 |
+
'<h3>Complaints per 10,000 units</h3><p class="hint">Enter units produced or sold each month; the rate updates when you leave the box.</p><div class="table-wrap"><table class="ht"><thead><tr><th>Month</th><th class="num">Complaints</th><th>Units</th><th class="num">Per 10,000</th></tr></thead><tbody>' + vol + "</tbody></table></div>" +
|
| 240 |
+
'<h3>Open complaints, oldest first</h3><table class="ht"><thead><tr><th>Ref</th><th>Category</th><th>Severity</th><th class="num">Days open</th></tr></thead><tbody>' + (open || "<tr><td colspan=4>None open</td></tr>") + "</tbody></table></div>";
|
| 241 |
+
}
|
| 242 |
+
function tile(label, v, small) { return '<div class="tile"><span>' + esc(label) + "</span><b>" + esc(v) + "</b>" + (small ? "<small>" + esc(small) + "</small>" : "") + "</div>"; }
|
| 243 |
+
|
| 244 |
+
// ---------- forms & binder ----------
|
| 245 |
+
function rForms() {
|
| 246 |
+
var el = $("#p-forms");
|
| 247 |
+
var cards = HZC.FORMS.map(function (f) { var locked = !f.free && !pro(); return '<div class="tier' + (locked ? " locked-tier" : "") + '"><h3>' + esc(f.name) + (f.free ? "" : ' <span class="pro-tag">PRO</span>') + '</h3><p class="hint">' + esc(f.note || ({ complaint: "One page per complaint, matching the register.", capa: "One page per corrective action.", allergen: "US 9 major allergens or the EU 14, one row per dish." })[f.kind] || "") + "</p>" +
|
| 248 |
+
'<button class="btn sm" type="button" data-act="print-form" data-id="' + f.id + '">' + (locked ? "Unlock with Pro" : "Print blank form") + "</button></div>"; }).join("");
|
| 249 |
+
el.innerHTML = '<div class="card"><div class="picker-head"><h2>Food safety binder</h2><div class="btns"><button class="btn" type="button" data-act="print-binder">Print the full binder <span class="pro-tag">PRO</span></button></div></div>' +
|
| 250 |
+
'<p class="hint">Cover page, HACCP plan(s), complaint procedure and register, CAPA log, saved audits and every blank form below, in one print job (save as PDF from the print dialog).</p>' +
|
| 251 |
+
'<label for="allergen-set">Allergen matrix list</label><select id="allergen-set"><option value="us">US: 9 major food allergens</option><option value="eu">EU/UK: 14 allergens</option></select></div>' +
|
| 252 |
+
'<div class="card"><h2>Blank record forms</h2><div class="tiers">' + cards + "</div></div>";
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// ---------- printing ----------
|
| 256 |
+
function credit() { return pro() ? "" : '<p class="pc-credit">Made with Hazelsprig (free) · cybermaxtools.com</p>'; }
|
| 257 |
+
function hdr(title) { var bz = S.biz; return '<header><div><h1>' + esc(title) + "</h1><p>" + esc([bz.name || "Your business", bz.site].filter(Boolean).join(" · ")) + '</p></div><p class="pc-date">Printed ' + HZ.today() + "</p></header>"; }
|
| 258 |
+
function page(title, body) { return '<section class="pcard">' + hdr(title) + body + credit() + "</section>"; }
|
| 259 |
+
function tbl(cols, rows) { return "<table><thead><tr>" + cols.map(function (c) { return "<th>" + esc(c) + "</th>"; }).join("") + "</tr></thead><tbody>" + rows.map(function (r) { return "<tr>" + r.map(function (v) { return "<td>" + esc(v) + "</td>"; }).join("") + "</tr>"; }).join("") + "</tbody></table>"; }
|
| 260 |
+
function sign() { return '<p class="pc-sign">Reviewed by: ____________________ Role: ______________ Date: ___________</p>'; }
|
| 261 |
+
function planHTML(p) {
|
| 262 |
+
var ccp = HZ.ccps(p), ha = [];
|
| 263 |
+
p.steps.forEach(function (s, i) { s.hazards.forEach(function (h) { var d = HZ.ccpDecision(h); ha.push([(i + 1) + ". " + s.name, h.type, h.hazard, h.cause, h.control, h.significant === "yes" ? "Yes" : h.significant === "no" ? "No" : "-", h.significant === "yes" ? (d.ccp ? "CCP" : d.done ? "No" : "?") : "-"]); }); });
|
| 264 |
+
return page("HACCP plan: " + (p.name || ""),
|
| 265 |
+
'<div class="pc-desc"><p><b>Product:</b> ' + esc(p.product) + "</p><p><b>Intended use and consumers:</b> " + esc(p.use) + "</p><p><b>Storage:</b> " + esc(p.storage) + "</p><p><b>Scope:</b> " + esc(p.scope) + "</p><p><b>HACCP team:</b> " + esc(p.team) + "</p></div>" +
|
| 266 |
+
"<h2>Process flow</h2><p>" + p.steps.map(function (s, i) { return (i + 1) + ". " + esc(s.name); }).join(" → ") + "</p>" +
|
| 267 |
+
"<h2>Hazard analysis</h2>" + tbl(["Step", "Type", "Hazard", "Cause", "Control", "Significant", "CCP"], ha) +
|
| 268 |
+
"<h2>CCP plan</h2>" + (ccp.length ? tbl(["CCP", "Step", "Hazard", "Critical limit", "Monitoring", "Frequency / who", "Corrective action", "Verification", "Records"], ccp.map(function (c) { var h = c.hazard; return [c.label, c.step.name, h.hazard, h.limit, h.monitor, [h.freq, h.who].filter(Boolean).join(" / "), h.correct, h.verify, h.records]; })) : "<p>No CCPs identified.</p>") +
|
| 269 |
+
sign() + '<p class="pc-small">Prepared with Hazelsprig, a record-keeping tool. The plan\'s content is the business\'s own and must be reviewed by a qualified person.</p>');
|
| 270 |
+
}
|
| 271 |
+
function registerHTML() {
|
| 272 |
+
return page("Customer complaint register", tbl(["Ref", "Date", "Channel", "Product", "Lot", "Category", "Severity", "Root cause", "Action", "Status", "Closed"],
|
| 273 |
+
S.complaints.map(function (c) { return [c.ref, c.date, c.channel, c.product, c.lot, HZ.catLabel(c.category), c.severity || HZ.suggestSeverity(c), c.rootCause, c.action, c.status, c.closed]; })) || "<p>No complaints logged.</p>");
|
| 274 |
+
}
|
| 275 |
+
function procedureHTML() {
|
| 276 |
+
var bz = S.biz, d = bz.reply || "2", lead = bz.lead || "the food safety lead";
|
| 277 |
+
return page("Customer complaint procedure", "<ol class=\"pc-proc\">" + [
|
| 278 |
+
"Any staff member can take a complaint. Thank the customer, write down what they say in their words, and record the date, product, batch/lot or date code, where and when it was bought, and how to reply.",
|
| 279 |
+
"Keep any sample, packaging or photo. Do not throw anything away until the complaint is closed.",
|
| 280 |
+
"Log it in the complaint register the same day and give it a reference number.",
|
| 281 |
+
"Set the severity. Critical: allergen reactions or undeclared allergens, illness or injury claims, and hard or sharp foreign bodies (glass, metal, hard plastic). Major: other foreign material, spoilage, labelling errors. Minor: quality and service.",
|
| 282 |
+
"Critical complaints go to " + esc(lead) + " at once. Hold any affected stock, review the recall plan and decide, with qualified advice, whether the regulator must be told.",
|
| 283 |
+
"Investigate in proportion to severity: check records for that batch, speak to staff, inspect retained samples. Use the 5 Whys and a cause category to find the root cause.",
|
| 284 |
+
"Fix it: record the immediate action and, where the cause could repeat, open a CAPA with an owner and a due date.",
|
| 285 |
+
"Reply to the customer within " + esc(d) + " working day" + (d === "1" ? "" : "s") + " of receipt and record what was said.",
|
| 286 |
+
"Close the complaint when the reply is sent and any CAPA is open with an owner. Close the CAPA only when its effectiveness has been checked.",
|
| 287 |
+
"Review complaint trends at least monthly (by month, category and product) and act on repeats.",
|
| 288 |
+
].map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol>" + '<p><b>Owner of this procedure:</b> ' + esc(lead) + "</p>" + sign());
|
| 289 |
+
}
|
| 290 |
+
function capaHTML() {
|
| 291 |
+
return page("CAPA log", tbl(["Ref", "Opened", "Source", "Link", "Problem", "Root cause", "Corrective action", "Owner", "Due", "Verified", "Status"],
|
| 292 |
+
S.capas.map(function (a) { return [a.ref, a.opened, a.source, a.link, a.problem, a.rootCause, a.corrective, a.owner, a.due, a.verifyDate, a.status]; })));
|
| 293 |
+
}
|
| 294 |
+
function auditHTML(a) {
|
| 295 |
+
var c = listById(a.list), ans = a.answers || {};
|
| 296 |
+
return page("Audit: " + c.name, "<p><b>Date:</b> " + esc(a.date) + " <b>Score:</b> " + (a.pct == null ? "-" : a.pct + "%") + " <b>Findings:</b> " + a.findings.length + "</p>" +
|
| 297 |
+
tbl(["#", "Check", "Answer", "Note"], c.items.map(function (it, i) { var x = ans[i] || {}; return [i + 1, it, ({ yes: "Yes", no: "No", na: "N/A" })[x.v] || "-", x.note || ""]; })) + sign());
|
| 298 |
+
}
|
| 299 |
+
function trendsHTML() {
|
| 300 |
+
var t = HZ.trends(S.complaints, { volumes: S.volumes });
|
| 301 |
+
return page("Complaint trend report", "<p><b>Logged:</b> " + t.total + " · <b>Open:</b> " + t.open.length + " · <b>Average days to close:</b> " + (t.avgClose == null ? "-" : t.avgClose) + " · <b>Last 3 months:</b> " + t.last3 + " (previous 3: " + t.prev3 + ")</p>" +
|
| 302 |
+
"<h2>By month</h2>" + tbl(["Month", "Complaints", "Units", "Per 10,000"], t.months.map(function (m) { return [m.month, m.count, m.volume || "", m.per10k == null ? "" : m.per10k]; })) +
|
| 303 |
+
"<h2>By category</h2>" + tbl(["Category", "Count", "Share %", "Cumulative %"], t.pareto.map(function (r) { return [r.label, r.count, r.share, r.cum]; })) +
|
| 304 |
+
"<h2>By product</h2>" + tbl(["Product", "Count"], t.products.map(function (r) { return [r.product, r.count]; })) + sign());
|
| 305 |
+
}
|
| 306 |
+
function formHTML(f) {
|
| 307 |
+
if (f.kind === "complaint") return page("Customer complaint form", '<div class="pc-boxes">' + ["Ref no.", "Date received", "Taken by", "Channel", "Customer name / contact", "Product", "Batch / lot / date code", "Where and when bought", "What happened (customer's words)", "Sample kept? Where?", "Category", "Severity (Critical / Major / Minor)", "Escalated to / when", "Investigation", "Root cause (5 Whys)", "Immediate action", "CAPA ref", "Reply given / date", "Closed by / date"].map(function (l) { return '<div class="pc-box' + (/happened|Investigation|Root cause/.test(l) ? " tall" : "") + '"><span>' + esc(l) + "</span></div>"; }).join("") + "</div>");
|
| 308 |
+
if (f.kind === "capa") return page("CAPA form", '<div class="pc-boxes">' + ["CAPA ref", "Opened", "Source (complaint / audit / inspection / deviation)", "Linked record", "Problem", "Root cause", "Correction (immediate)", "Corrective action", "Preventive action", "Owner", "Due date", "Effectiveness check: date and evidence", "Closed by / date"].map(function (l) { return '<div class="pc-box' + (/Problem|Root|action/.test(l) ? " tall" : "") + '"><span>' + esc(l) + "</span></div>"; }).join("") + "</div>");
|
| 309 |
+
if (f.kind === "allergen") {
|
| 310 |
+
var set = ($("#allergen-set") || {}).value === "eu" ? HZC.EU_ALLERGENS : HZC.US_ALLERGENS;
|
| 311 |
+
var rows = []; for (var i = 0; i < 18; i++) rows.push(["" ].concat(set.map(function () { return ""; })));
|
| 312 |
+
return page("Allergen matrix", '<p class="pc-small">Tick each allergen a dish or product contains. Update it whenever a recipe or supplier changes.</p><table class="pc-matrix"><thead><tr><th>Dish / product</th>' + set.map(function (a) { return "<th><span>" + esc(a) + "</span></th>"; }).join("") + "</tr></thead><tbody>" + rows.map(function (r) { return "<tr>" + r.map(function () { return "<td></td>"; }).join("") + "</tr>"; }).join("") + "</tbody></table>");
|
| 313 |
+
}
|
| 314 |
+
var blankRows = []; for (var j = 0; j < f.rows; j++) blankRows.push(f.cols.map(function () { return ""; }));
|
| 315 |
+
return page(f.name, '<p class="pc-small">' + esc(f.note || "") + "</p>" + tbl(f.cols, blankRows).replace("<table>", '<table class="pc-blank">'));
|
| 316 |
+
}
|
| 317 |
+
function doPrint(html) { var a = $("#print-area"); a.innerHTML = html; setTimeout(function () { window.print(); }, 60); }
|
| 318 |
+
|
| 319 |
+
// ---------- actions ----------
|
| 320 |
+
document.addEventListener("click", function (e) {
|
| 321 |
+
var t = e.target.closest("[data-act]"); if (!t) return;
|
| 322 |
+
var act = t.getAttribute("data-act"), id = t.getAttribute("data-id"), p = plan();
|
| 323 |
+
switch (act) {
|
| 324 |
+
case "new-plan":
|
| 325 |
+
if (!HZ.canAdd("plans", S.plans.length, pro())) return limitHit("plans", "Free covers 1 HACCP plan. Pro adds unlimited plans.");
|
| 326 |
+
var np = HZ.newPlan("HACCP plan " + (S.plans.length + 1)); np.steps.push(HZ.newStep("Receive")); S.plans.push(np); S.planId = np.id; break;
|
| 327 |
+
case "pick-plan": S.planId = id; break;
|
| 328 |
+
case "del-plan": if (!confirm("Delete this HACCP plan? This cannot be undone.")) return; S.plans = S.plans.filter(function (x) { return x.id !== p.id; }); S.planId = null; break;
|
| 329 |
+
case "add-step": p.steps.push(HZ.newStep("")); break;
|
| 330 |
+
case "del-step": if (!confirm("Delete this step and its hazards?")) return; p.steps = p.steps.filter(function (s) { return s.id !== id; }); break;
|
| 331 |
+
case "step-up": case "step-down":
|
| 332 |
+
var i = p.steps.findIndex(function (s) { return s.id === id; }), j = act === "step-up" ? i - 1 : i + 1;
|
| 333 |
+
if (j >= 0 && j < p.steps.length) { var tmp = p.steps[i]; p.steps[i] = p.steps[j]; p.steps[j] = tmp; } break;
|
| 334 |
+
case "add-hz": find(p.steps, id).hazards.push(HZ.newHazard()); break;
|
| 335 |
+
case "del-hz": p.steps.forEach(function (s) { s.hazards = s.hazards.filter(function (h) { return h.id !== id; }); }); break;
|
| 336 |
+
case "print-plan": return doPrint(planHTML(p));
|
| 337 |
+
case "new-c":
|
| 338 |
+
if (!HZ.canAdd("complaints", S.complaints.length, pro())) return limitHit("complaints", "Free covers 15 complaints. Pro adds unlimited complaints and the trend report.");
|
| 339 |
+
var c = HZ.newComplaint(seq(S.complaints, "C")); S.complaints.push(c); S.ui.cId = c.id; break;
|
| 340 |
+
case "pick-c": S.ui.cId = id; break;
|
| 341 |
+
case "del-c": if (!confirm("Delete this complaint record?")) return; S.complaints = S.complaints.filter(function (x) { return x.id !== id; }); S.ui.cId = null; break;
|
| 342 |
+
case "c-to-capa":
|
| 343 |
+
if (!HZ.canAdd("capas", S.capas.length, pro())) return limitHit("capas", "Free covers 5 CAPAs. Pro adds unlimited CAPAs.");
|
| 344 |
+
var cc = find(S.complaints, id), a = HZ.newCapa(seq(S.capas, "CAPA"));
|
| 345 |
+
Object.assign(a, { source: "Customer complaint", link: cc.ref, problem: [HZ.catLabel(cc.category), cc.product, cc.desc].filter(Boolean).join(": "), rootCause: cc.rootCause });
|
| 346 |
+
S.capas.push(a); cc.capa = a.id; S.ui.aId = a.id; save(); return setTab("capa");
|
| 347 |
+
case "goto-capa": S.ui.aId = id; save(); return setTab("capa");
|
| 348 |
+
case "print-reg": return doPrint(registerHTML());
|
| 349 |
+
case "print-proc": return doPrint(procedureHTML());
|
| 350 |
+
case "csv-c":
|
| 351 |
+
if (!pro()) return limitHit("csv", "CSV export is part of Pro.");
|
| 352 |
+
return P.download("hazelsprig-complaints.csv", HZ.toCSV(S.complaints, [["ref", "Ref"], ["date", "Date"], ["channel", "Channel"], ["product", "Product"], ["lot", "Lot"], ["category", "Category"], ["severity", "Severity"], ["desc", "Description"], ["investigation", "Investigation"], ["whys", "5 Whys"], ["causeCat", "Cause category"], ["rootCause", "Root cause"], ["action", "Action"], ["response", "Reply"], ["responded", "Replied"], ["status", "Status"], ["closed", "Closed"]].map(function (x) { return { key: x[0], label: x[1] }; })));
|
| 353 |
+
case "new-a":
|
| 354 |
+
if (!HZ.canAdd("capas", S.capas.length, pro())) return limitHit("capas", "Free covers 5 CAPAs. Pro adds unlimited CAPAs.");
|
| 355 |
+
var na = HZ.newCapa(seq(S.capas, "CAPA")); S.capas.push(na); S.ui.aId = na.id; break;
|
| 356 |
+
case "pick-a": S.ui.aId = id; break;
|
| 357 |
+
case "del-a": if (!confirm("Delete this CAPA?")) return; S.capas = S.capas.filter(function (x) { return x.id !== id; }); S.complaints.forEach(function (x) { if (x.capa === id) x.capa = ""; }); S.ui.aId = null; break;
|
| 358 |
+
case "print-capa": return doPrint(capaHTML());
|
| 359 |
+
case "csv-a":
|
| 360 |
+
if (!pro()) return limitHit("csv", "CSV export is part of Pro.");
|
| 361 |
+
return P.download("hazelsprig-capa.csv", HZ.toCSV(S.capas, ["ref", "opened", "source", "link", "problem", "rootCause", "correction", "corrective", "preventive", "owner", "due", "verifyDate", "effective", "status", "closed"].map(function (k) { return { key: k, label: k }; })));
|
| 362 |
+
case "start-audit":
|
| 363 |
+
var cl = listById(id); if (!cl.free && !pro()) return limitHit("audits", "The " + cl.name + " checklist is part of Pro.");
|
| 364 |
+
S.ui.run = { list: id, date: HZ.today(), answers: {} }; break;
|
| 365 |
+
case "ans": var r = S.ui.run; r.answers[id] = r.answers[id] || {}; r.answers[id].v = t.getAttribute("data-v"); break;
|
| 366 |
+
case "cancel-audit": S.ui.run = null; break;
|
| 367 |
+
case "save-audit":
|
| 368 |
+
var run = S.ui.run, sc = HZ.auditScore(listById(run.list).items, run.answers);
|
| 369 |
+
if (sc.open) { if (!confirm(sc.open + " checks are not answered yet. Save anyway?")) return; }
|
| 370 |
+
var rec = { id: HZ.uid("u"), list: run.list, date: run.date, answers: run.answers, pct: sc.pct, findings: sc.findings };
|
| 371 |
+
S.audits.push(rec); S.ui.run = null;
|
| 372 |
+
if (sc.findings.length && confirm("Create a CAPA for each of the " + sc.findings.length + " findings?")) {
|
| 373 |
+
sc.findings.forEach(function (f) { if (!HZ.canAdd("capas", S.capas.length, pro())) return; var x = HZ.newCapa(seq(S.capas, "CAPA")); Object.assign(x, { source: "Internal audit", link: listById(run.list).name + " " + run.date, problem: f.text + (f.note ? ": " + f.note : "") }); S.capas.push(x); });
|
| 374 |
+
if (!pro() && S.capas.length >= HZ.FREE.capas) limitHit("capas", "Free covers 5 CAPAs; Pro logs every finding.");
|
| 375 |
+
}
|
| 376 |
+
break;
|
| 377 |
+
case "print-audit": return doPrint(auditHTML(S.audits.filter(function (x) { return x.id === id; })[0]));
|
| 378 |
+
case "upsell-trends": return limitHit("trends", "The complaint trend report is part of Pro.");
|
| 379 |
+
case "print-trends": return doPrint(trendsHTML());
|
| 380 |
+
case "print-form":
|
| 381 |
+
var f = HZC.FORMS.filter(function (x) { return x.id === id; })[0];
|
| 382 |
+
if (!f.free && !pro()) return limitHit("forms", "The " + f.name + " is part of Pro.");
|
| 383 |
+
return doPrint(formHTML(f));
|
| 384 |
+
case "print-binder":
|
| 385 |
+
if (!pro()) return limitHit("binder", "Printing the full binder is part of Pro.");
|
| 386 |
+
var bz = S.biz, cover = '<section class="pcard pc-cover"><h1>Food safety binder</h1><p class="big">' + esc(bz.name || "Your business") + "</p><p>" + esc(bz.site || "") + "</p><p>Food safety lead: " + esc(bz.lead || "") + "</p><p>Printed " + HZ.today() + "</p><ol>" +
|
| 387 |
+
["HACCP plan" + (S.plans.length > 1 ? "s" : ""), "Customer complaint procedure", "Customer complaint register", "CAPA log", "Audit reports", "Blank record forms"].map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol></section>";
|
| 388 |
+
return doPrint(cover + S.plans.map(planHTML).join("") + procedureHTML() + registerHTML() + capaHTML() + S.audits.map(auditHTML).join("") + HZC.FORMS.map(formHTML).join(""));
|
| 389 |
+
default: return;
|
| 390 |
+
}
|
| 391 |
+
save(); render();
|
| 392 |
+
});
|
| 393 |
+
|
| 394 |
+
// ---------- examples ----------
|
| 395 |
+
$("#btn-example").addEventListener("click", function () {
|
| 396 |
+
var k = $("#example-kind").value, ex = HZC.EXAMPLES[k];
|
| 397 |
+
if (!HZ.canAdd("plans", S.plans.length, pro())) {
|
| 398 |
+
if (!confirm("Free covers 1 HACCP plan. Replace your current plan with the example?")) return limitHit("plans", "Pro adds unlimited HACCP plans.");
|
| 399 |
+
S.plans = [];
|
| 400 |
+
}
|
| 401 |
+
var p = HZ.newPlan(ex.name);
|
| 402 |
+
["product", "use", "consumers", "storage", "team", "scope"].forEach(function (f) { p[f] = ex[f]; });
|
| 403 |
+
p.steps = ex.steps.map(function (s) { var st = HZ.newStep(s.name); st.hazards = s.hazards.map(function (h) { return Object.assign(HZ.newHazard(), JSON.parse(JSON.stringify(h))); }); return st; });
|
| 404 |
+
S.plans.push(p); S.planId = p.id;
|
| 405 |
+
if (!S.complaints.length) {
|
| 406 |
+
var prod = { poultry: "Grilled chicken plate", deli: "Ham & cheese sandwich", bakery: "Chocolate chip cookies" }[k];
|
| 407 |
+
var demo = [
|
| 408 |
+
{ d: -40, cat: "quality", desc: "Said it was dry", st: "Closed", cause: "Method", root: "Held too long before service", sev: "Minor" },
|
| 409 |
+
{ d: -21, cat: "foreign", desc: "Found a hair in the food", st: "Closed", cause: "People", root: "Hair restraint not worn on one shift", sev: "Major" },
|
| 410 |
+
{ d: -9, cat: "allergen", desc: "Customer says they reacted; they had told staff about a sesame allergy", st: "Investigating", cause: "", root: "", sev: "" },
|
| 411 |
+
];
|
| 412 |
+
demo.forEach(function (x) {
|
| 413 |
+
var c = HZ.newComplaint(seq(S.complaints, "C"));
|
| 414 |
+
var dt = new Date(Date.now() + x.d * 864e5).toISOString().slice(0, 10);
|
| 415 |
+
Object.assign(c, { date: dt, product: prod, category: x.cat, desc: x.desc, status: x.st, causeCat: x.cause, rootCause: x.root, severity: x.sev, lot: "Example", customer: "Example customer",
|
| 416 |
+
closed: x.st === "Closed" ? new Date(Date.now() + (x.d + 3) * 864e5).toISOString().slice(0, 10) : "" });
|
| 417 |
+
S.complaints.push(c);
|
| 418 |
+
});
|
| 419 |
+
}
|
| 420 |
+
if (!S.biz.name) S.biz.name = "Example Kitchen";
|
| 421 |
+
fillBiz(); save(); setTab("plan"); toast("Example loaded. Edit anything; it is saved on this device only.");
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
// ---------- business settings ----------
|
| 425 |
+
function fillBiz() { ["name", "site", "lead", "reply"].forEach(function (k) { var el = $("#b-" + k); el.value = S.biz[k] || ""; el.setAttribute("data-b", "biz:" + k); }); }
|
| 426 |
+
fillBiz();
|
| 427 |
+
|
| 428 |
+
// ---------- data ----------
|
| 429 |
+
$("#btn-backup").addEventListener("click", function () {
|
| 430 |
+
if (!pro()) return limitHit("backup", "JSON backup is part of Pro.");
|
| 431 |
+
P.download("hazelsprig-backup-" + HZ.today() + ".json", JSON.stringify(S, null, 1), "application/json");
|
| 432 |
+
});
|
| 433 |
+
$("#f-restore").addEventListener("change", function (e) {
|
| 434 |
+
var f = e.target.files[0]; if (!f) return;
|
| 435 |
+
f.text().then(function (txt) {
|
| 436 |
+
var d = JSON.parse(txt); if (!d || d.v !== 1 || !Array.isArray(d.complaints)) throw new Error("bad");
|
| 437 |
+
if (!confirm("Replace everything on this device with this backup?")) return;
|
| 438 |
+
S = d; S.ui = S.ui || { tab: "plan" }; fillBiz(); save(); setTab("plan"); toast("Backup restored.");
|
| 439 |
+
}).catch(function () { toast("That file is not a Hazelsprig backup."); });
|
| 440 |
+
e.target.value = "";
|
| 441 |
+
});
|
| 442 |
+
$("#btn-clear").addEventListener("click", function () {
|
| 443 |
+
if (!confirm("Delete every plan, complaint, CAPA and audit on this device? Make a backup first if you need one.")) return;
|
| 444 |
+
S = blank(); fillBiz(); save(); setTab("plan");
|
| 445 |
+
});
|
| 446 |
+
|
| 447 |
+
// ---------- PWA ----------
|
| 448 |
+
var deferred;
|
| 449 |
+
window.addEventListener("beforeinstallprompt", function (e) { e.preventDefault(); deferred = e; $("#btn-install").hidden = false; });
|
| 450 |
+
$("#btn-install").addEventListener("click", function () { if (deferred) { deferred.prompt(); deferred = null; $("#btn-install").hidden = true; } });
|
| 451 |
+
if ("serviceWorker" in navigator && location.protocol === "https:") navigator.serviceWorker.register("sw.js").catch(function () {});
|
| 452 |
+
|
| 453 |
+
if (P) P.onChange(function () { render(); });
|
| 454 |
+
setTab(TABS.indexOf(S.ui.tab) >= 0 ? S.ui.tab : "plan");
|
| 455 |
+
})();
|
hazelsprig/icon-192.png
ADDED
|
|
hazelsprig/icon-512.png
ADDED
|
|
hazelsprig/icon.svg
ADDED
|
|
hazelsprig/index.html
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>HACCP Plan Builder, Complaint Log & CAPA | Hazelsprig</title>
|
| 7 |
+
<meta name="description" content="Free HACCP plan builder with a CCP decision tree, customer complaint register with root cause and trend reports, CAPA log, audit checklists and printable food safety logs. For restaurants, bakeries, caterers and small food makers. Nothing uploaded.">
|
| 8 |
+
<meta name="keywords" content="HACCP plan builder, HACCP plan template, customer complaint log, food complaint register, CAPA log, corrective action log, food safety audit checklist, GMP checklist, SSOP checklist, allergen matrix, cooling log, food safety binder">
|
| 9 |
+
<meta name="theme-color" content="#2E6B45">
|
| 10 |
+
<link rel="canonical" href="https://cybermax-tools-cybermax.static.hf.space/hazelsprig/index.html">
|
| 11 |
+
<link rel="manifest" href="manifest.webmanifest">
|
| 12 |
+
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
| 13 |
+
<link rel="apple-touch-icon" href="icon-192.png">
|
| 14 |
+
<meta property="og:type" content="website">
|
| 15 |
+
<meta property="og:site_name" content="CyberMax">
|
| 16 |
+
<meta property="og:title" content="Hazelsprig: HACCP plan builder, complaint log and CAPA in one app">
|
| 17 |
+
<meta property="og:description" content="Build your HACCP plan, log customer complaints with root cause and trends, track CAPAs and run GMP, SSOP and allergen audits. Free, no sign-up, nothing uploaded.">
|
| 18 |
+
<meta property="og:url" content="https://cybermax-tools-cybermax.static.hf.space/hazelsprig/index.html">
|
| 19 |
+
<meta property="og:image" content="https://cybermax-tools-cybermax.static.hf.space/hazelsprig/og.png">
|
| 20 |
+
<meta name="twitter:card" content="summary_large_image">
|
| 21 |
+
<script type="application/ld+json">
|
| 22 |
+
{"@context":"https://schema.org","@graph":[
|
| 23 |
+
{"@type":"WebApplication","name":"Hazelsprig","alternateName":"Hazelsprig HACCP plan builder and complaint log","url":"https://cybermax-tools-cybermax.static.hf.space/hazelsprig/index.html","image":"https://cybermax-tools-cybermax.static.hf.space/hazelsprig/og.png","applicationCategory":"BusinessApplication","operatingSystem":"Any (web browser)","browserRequirements":"Requires JavaScript","description":"Food safety documentation app for restaurants, bakeries, caterers and small food makers: HACCP plan builder with hazard analysis and a CCP decision tree, customer complaint register with 5 Whys root cause and trend reports, CAPA log with effectiveness checks, GMP, SSOP, allergen and internal audit checklists, and printable record forms. Works offline; data stays in the browser.","offers":[{"@type":"Offer","name":"Free","price":"0","priceCurrency":"USD","description":"1 HACCP plan, 15 complaints, 5 CAPAs, GMP, SSOP and allergen checklists, 2 printable forms"},{"@type":"Offer","name":"Hazelsprig Pro Monthly","price":"19","priceCurrency":"USD","description":"Unlimited plans and records, complaint trend report, all checklists and forms, full binder print, CSV export, JSON backup; cancel any time"},{"@type":"Offer","name":"Hazelsprig Pro Yearly","price":"190","priceCurrency":"USD"},{"@type":"Offer","name":"Hazelsprig Pro Lifetime","price":"290","priceCurrency":"USD","description":"Every Pro feature, pay once"}],"publisher":{"@type":"Organization","name":"CyberMax","email":"cybermax.tools@gmail.com"}},
|
| 24 |
+
{"@type":"FAQPage","mainEntity":[
|
| 25 |
+
{"@type":"Question","name":"Does Hazelsprig write my HACCP plan for me?","acceptedAnswer":{"@type":"Answer","text":"No. It gives you the structure (product description, flow steps, hazard analysis, the CCP decision tree, critical limits, monitoring, corrective actions, verification and records) and three editable examples. You and your qualified food safety lead decide the content; your regulator or certification body has the final say."}},
|
| 26 |
+
{"@type":"Question","name":"What is a customer complaint programme?","acceptedAnswer":{"@type":"Answer","text":"A written way to take every complaint, log it with the product and batch, judge its severity, investigate the root cause, fix it with a corrective action, reply to the customer and review trends. Hazelsprig gives you the register, a 5 Whys and cause-category root cause, CAPA links, a printable procedure and a trend report."}},
|
| 27 |
+
{"@type":"Question","name":"Is my data private?","acceptedAnswer":{"@type":"Answer","text":"Yes. Hazelsprig runs entirely in your browser and saves only on this device. Nothing is uploaded. Pro adds a JSON backup so you can move your records to another device."}},
|
| 28 |
+
{"@type":"Question","name":"Why not FoodDocs or SafetyCulture?","acceptedAnswer":{"@type":"Answer","text":"They are full platforms with team apps, sensors and cloud sync. FoodDocs lists $199 a month per site for its HACCP plan tier and SafetyCulture lists Premium at $24 per seat a month billed annually (both checked 16 Oct 2026). If you need the documents and logs without a per-seat subscription, Hazelsprig is free to start and Pro is $19 a month or $290 once."}},
|
| 29 |
+
{"@type":"Question","name":"Can I cancel?","acceptedAnswer":{"@type":"Answer","text":"Yes. Monthly and yearly plans cancel any time from the link in your Stripe receipt email; your records stay in your browser. Lifetime is one payment with nothing to cancel."}}]}
|
| 30 |
+
]}
|
| 31 |
+
</script>
|
| 32 |
+
<link rel="stylesheet" href="style.css">
|
| 33 |
+
</head>
|
| 34 |
+
<body>
|
| 35 |
+
<header class="top"><div class="wrap top-in">
|
| 36 |
+
<a class="brand" href="index.html" aria-label="Hazelsprig home"><img src="icon.svg" alt="" width="30" height="30"><span><b>Hazelsprig</b> <small>by CyberMax</small></span></a>
|
| 37 |
+
<nav class="top-nav">
|
| 38 |
+
<button class="btn ghost sm" id="btn-install" type="button" hidden>Install app</button>
|
| 39 |
+
<a href="#compare" class="top-link">Compare</a>
|
| 40 |
+
<a href="#cmx-pro" class="top-link">Pricing</a>
|
| 41 |
+
<a href="#cmx-pro" class="pill" id="pro-pill">Pro</a>
|
| 42 |
+
</nav>
|
| 43 |
+
</div></header>
|
| 44 |
+
|
| 45 |
+
<main>
|
| 46 |
+
<div class="wrap">
|
| 47 |
+
<section class="hero">
|
| 48 |
+
<h1>HACCP plan, complaint log and CAPA in one app</h1>
|
| 49 |
+
<p class="lede">For restaurants, bakeries, caterers, food trucks and small food makers. Build your HACCP plan with the CCP decision tree, log every customer complaint with its root cause, track corrective actions to closure and run GMP, sanitation and allergen audits. Print it all as a binder. <b>Free, no sign-up, nothing uploaded.</b></p>
|
| 50 |
+
<div class="hero-row">
|
| 51 |
+
<button class="btn" id="btn-example" type="button">Load example</button>
|
| 52 |
+
<label class="sel-inline" for="example-kind"><span class="sr">Example</span>
|
| 53 |
+
<select id="example-kind">
|
| 54 |
+
<option value="poultry">Restaurant: grilled chicken (cook and serve)</option>
|
| 55 |
+
<option value="deli">Cafe: cold deli sandwiches</option>
|
| 56 |
+
<option value="bakery">Bakery: cookies and cakes with allergens</option>
|
| 57 |
+
</select></label>
|
| 58 |
+
<span class="hero-note">Free: 1 HACCP plan · 15 complaints · 5 CAPAs · 3 checklists · works offline</span>
|
| 59 |
+
</div>
|
| 60 |
+
</section>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<section id="app" class="app wrap" aria-label="Food safety records">
|
| 64 |
+
<div class="assume card" id="biz">
|
| 65 |
+
<div class="assume-title"><b>Your business</b> <span class="hint">printed on every report; saved on this device only</span></div>
|
| 66 |
+
<div class="assume-grid">
|
| 67 |
+
<div><label for="b-name">Business name</label><input id="b-name" autocomplete="organization" placeholder="e.g. Corner Kitchen"></div>
|
| 68 |
+
<div><label for="b-site">Site / address <span class="opt">(optional)</span></label><input id="b-site" placeholder="e.g. 12 Main St"></div>
|
| 69 |
+
<div><label for="b-lead">Food safety lead (role)</label><input id="b-lead" placeholder="e.g. Owner"></div>
|
| 70 |
+
<div><label for="b-reply">Reply to complaints within (days)</label><input id="b-reply" inputmode="numeric" value="2"></div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="tabs" role="tablist" aria-label="Sections">
|
| 75 |
+
<button type="button" role="tab" id="tab-plan" aria-controls="p-plan" aria-selected="true">HACCP plan</button>
|
| 76 |
+
<button type="button" role="tab" id="tab-complaints" aria-controls="p-complaints" aria-selected="false">Complaints</button>
|
| 77 |
+
<button type="button" role="tab" id="tab-capa" aria-controls="p-capa" aria-selected="false">CAPA</button>
|
| 78 |
+
<button type="button" role="tab" id="tab-audits" aria-controls="p-audits" aria-selected="false">Audits</button>
|
| 79 |
+
<button type="button" role="tab" id="tab-trends" aria-controls="p-trends" aria-selected="false">Trends <span class="pro-tag">PRO</span></button>
|
| 80 |
+
<button type="button" role="tab" id="tab-forms" aria-controls="p-forms" aria-selected="false">Forms & binder</button>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div role="tabpanel" id="p-plan" aria-labelledby="tab-plan"></div>
|
| 84 |
+
<div role="tabpanel" id="p-complaints" aria-labelledby="tab-complaints" hidden></div>
|
| 85 |
+
<div role="tabpanel" id="p-capa" aria-labelledby="tab-capa" hidden></div>
|
| 86 |
+
<div role="tabpanel" id="p-audits" aria-labelledby="tab-audits" hidden></div>
|
| 87 |
+
<div role="tabpanel" id="p-trends" aria-labelledby="tab-trends" hidden></div>
|
| 88 |
+
<div role="tabpanel" id="p-forms" aria-labelledby="tab-forms" hidden></div>
|
| 89 |
+
|
| 90 |
+
<div class="card data-row">
|
| 91 |
+
<span class="limit" id="usage"></span>
|
| 92 |
+
<div class="btns">
|
| 93 |
+
<button class="btn ghost sm" id="btn-backup" type="button">Backup (JSON) <span class="pro-tag">PRO</span></button>
|
| 94 |
+
<label class="btn ghost sm file-btn">Restore backup<input type="file" id="f-restore" accept="application/json,.json" hidden></label>
|
| 95 |
+
<button class="btn ghost sm danger" id="btn-clear" type="button">Clear all data</button>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</section>
|
| 99 |
+
|
| 100 |
+
<div class="wrap">
|
| 101 |
+
<section class="card pro-cta" id="pro-cta">
|
| 102 |
+
<div><b>Audit coming up?</b><p>Pro prints the whole food safety binder in one go (HACCP plan, complaint procedure and register, CAPA log, audit reports and 10 blank record forms), adds the complaint trend report and removes every limit.</p></div>
|
| 103 |
+
<a class="btn" href="#cmx-pro">See Pro from $19/month</a>
|
| 104 |
+
</section>
|
| 105 |
+
|
| 106 |
+
<section class="how" id="how">
|
| 107 |
+
<h2>How it works</h2>
|
| 108 |
+
<ol class="steps">
|
| 109 |
+
<li><b>Build your HACCP plan.</b> Describe the product, list each step from receiving to service, analyse biological, chemical, physical and allergen hazards, and answer the four CCP questions. Set critical limits, monitoring, corrective actions, verification and records for each CCP.</li>
|
| 110 |
+
<li><b>Log complaints the same way every time.</b> Product, batch, category and severity, then a 5 Whys and a cause category. Allergen, illness and hard foreign-body complaints are flagged at once with what to check next.</li>
|
| 111 |
+
<li><b>Close the loop.</b> Turn complaints and audit findings into CAPAs with owners and due dates. A CAPA closes only after you record that the fix worked. Print the binder for your inspector or auditor.</li>
|
| 112 |
+
</ol>
|
| 113 |
+
</section>
|
| 114 |
+
|
| 115 |
+
<section class="compare" id="compare">
|
| 116 |
+
<h2>Hazelsprig vs other food safety software</h2>
|
| 117 |
+
<div class="table-wrap">
|
| 118 |
+
<table class="cmp">
|
| 119 |
+
<thead><tr><th scope="col">Tool</th><th scope="col">Price</th><th scope="col">Good to know</th></tr></thead>
|
| 120 |
+
<tbody>
|
| 121 |
+
<tr class="us"><th scope="row">Hazelsprig Free</th><td>$0</td><td>1 HACCP plan, 15 complaints, 5 CAPAs, GMP, SSOP and allergen checklists, 2 printable forms; no account, works offline, nothing uploaded</td></tr>
|
| 122 |
+
<tr class="us"><th scope="row">Hazelsprig Pro</th><td>$19/month, $190/year or <b>$290 once</b></td><td>Unlimited plans and records, complaint trend report, 7 checklists, 10 forms, full binder print, CSV export, JSON backup</td></tr>
|
| 123 |
+
<tr><th scope="row"><a href="https://www.fooddocs.com/pricing" target="_blank" rel="noopener nofollow">FoodDocs</a></th><td>Lite $99, Standard (HACCP plan) $199, Professional $299 per site/month (monthly billing)</td><td>Full platform with a monitoring app, SOPs and traceability</td></tr>
|
| 124 |
+
<tr><th scope="row"><a href="https://mitti.com/pricing" target="_blank" rel="noopener nofollow">SafetyCulture</a></th><td>Free up to 10 seats; Premium $24/seat/month billed annually or $29 monthly</td><td>General inspection platform with HACCP templates and team apps</td></tr>
|
| 125 |
+
<tr><th scope="row"><a href="https://www.capterra.com/p/10039220/BatchBoost/" target="_blank" rel="noopener nofollow">BatchBoost</a></th><td>From $19.99/month</td><td>Traceability and allergen matrix for small food producers</td></tr>
|
| 126 |
+
<tr><th scope="row"><a href="https://www.capterra.com/p/10044795/EazyHaccp/" target="_blank" rel="noopener nofollow">EazyHaccp</a></th><td>From €29/month</td><td>Digital HACCP records for food service</td></tr>
|
| 127 |
+
</tbody>
|
| 128 |
+
</table>
|
| 129 |
+
</div>
|
| 130 |
+
<p class="hint">Rival prices checked 16 Oct 2026 from the linked pages (FoodDocs and SafetyCulture from their pricing pages; BatchBoost and EazyHaccp from their Capterra listings). Prices change; check their sites.</p>
|
| 131 |
+
</section>
|
| 132 |
+
|
| 133 |
+
<section class="faq" id="faq">
|
| 134 |
+
<h2>Questions</h2>
|
| 135 |
+
<details><summary>Does Hazelsprig write my HACCP plan for me?</summary><p>No. It gives you the structure (product description, flow steps, hazard analysis, the CCP decision tree, critical limits, monitoring, corrective actions, verification and records) and three editable examples. You and your qualified food safety lead decide what goes in it, and your regulator or certification body has the final say.</p></details>
|
| 136 |
+
<details><summary>How does the CCP decision tree work?</summary><p>For each significant hazard at a step, Hazelsprig asks the four classic questions: do control measures exist here; is this step designed to eliminate or reduce the hazard; could contamination reach unacceptable levels here; and will a later step remove it. Your answers decide whether the step is a CCP, and every CCP is numbered in flow order.</p></details>
|
| 137 |
+
<details><summary>What is a customer complaint programme?</summary><p>A written way to take every complaint, log it with the product and batch, judge its severity, find the root cause, fix it with a corrective action, reply to the customer and review trends. Hazelsprig gives you the register, a 5 Whys and cause-category root cause, CAPA links, a printable procedure and (Pro) a trend report by month, category and product, with complaints per 10,000 units if you enter volumes.</p></details>
|
| 138 |
+
<details><summary>Where do the example temperatures come from?</summary><p>From the US FDA Food Code 2022 (for example poultry cooked to 165°F, hot holding at 135°F or above, cold holding at 41°F or below, and two-stage cooling). Local codes differ, so confirm every limit with your own regulator.</p></details>
|
| 139 |
+
<details><summary>Why not FoodDocs or SafetyCulture?</summary><p>They are full platforms with team apps, sensors and cloud sync. FoodDocs lists $199 a month per site for its HACCP plan tier, and SafetyCulture lists Premium at $24 per seat a month billed annually (both checked 16 Oct 2026). If you need the documents, logs and complaint programme without a per-seat subscription, Hazelsprig is free to start and Pro is $19 a month or $290 once.</p></details>
|
| 140 |
+
<details><summary>Why not a Word or Excel template?</summary><p>A template is a blank page: you keep the numbering, links between complaints and CAPAs, overdue dates and trend counts by hand. Hazelsprig numbers CCPs, flags critical complaints, links them to CAPAs, warns when a CAPA is overdue and counts trends for you, and still prints clean documents.</p></details>
|
| 141 |
+
<details><summary>Is my data private?</summary><p>Yes. Hazelsprig runs entirely in your browser and saves only on this device. Nothing is uploaded. Pro adds a JSON backup so you can move your records to another device. Clearing your browser data deletes your records, so keep backups.</p></details>
|
| 142 |
+
<details><summary>Can my team use it?</summary><p>Hazelsprig keeps records on one device (a shared kitchen tablet works well). For several devices, move records with the Pro backup. If you need live multi-user sync, a platform like those above may suit you better.</p></details>
|
| 143 |
+
<details><summary>Can I cancel?</summary><p>Yes. Monthly and yearly plans cancel any time from the link in your Stripe receipt email, and Pro switches off by itself; your records stay. Lifetime is one payment with nothing to cancel.</p></details>
|
| 144 |
+
</section>
|
| 145 |
+
|
| 146 |
+
<p class="disclaimer">Hazelsprig is a record-keeping and document tool. It does not replace a qualified food safety professional, your regulator or your certification body, and it does not decide whether you must report or recall a product. Example content is a starting point to edit, not an approved plan.</p>
|
| 147 |
+
</div>
|
| 148 |
+
</main>
|
| 149 |
+
|
| 150 |
+
<div class="wrap">
|
| 151 |
+
<footer class="more">
|
| 152 |
+
<div class="more-title">More free tools for food businesses from CyberMax</div>
|
| 153 |
+
<div class="more-links">
|
| 154 |
+
<a href="https://cybermax-tools-cybermax.static.hf.space/platesprig/index.html" target="_blank" rel="noopener">Platesprig · food cost & menu prices</a>
|
| 155 |
+
<a href="https://cybermax-tools-cybermax.static.hf.space/menumoor/index.html" target="_blank" rel="noopener">Menumoor · printed menus & price lists</a>
|
| 156 |
+
<a href="https://cybermax-tools-cybermax.static.hf.space/tagwren/index.html" target="_blank" rel="noopener">Tagwren · price tags & labels</a>
|
| 157 |
+
<a href="https://cybermax-tools-cybermax.static.hf.space/rotasprig/index.html" target="_blank" rel="noopener">Rotasprig · staff rota</a>
|
| 158 |
+
<a href="https://cybermaxtools.com/store/lanewren/?utm_source=hazelsprig&utm_medium=more-from" target="_blank" rel="noopener">Lanewren · pool & spa chemistry log</a>
|
| 159 |
+
<a href="https://cybermaxtools.com/store/?utm_source=hazelsprig&utm_medium=more-from" target="_blank" rel="noopener">CyberMax Store</a>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="copy">© 2026 CyberMax. All rights reserved. · Support: <a href="mailto:cybermax.tools@gmail.com">cybermax.tools@gmail.com</a></div>
|
| 162 |
+
</footer>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div id="print-area" aria-hidden="true"></div>
|
| 166 |
+
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
| 167 |
+
<script>window.CMX_PRO={"id":"hazelsprig","name":"Hazelsprig","free":"1 HACCP plan, 15 complaints, 5 CAPAs, the GMP, SSOP and allergen checklists and 2 printable forms (reports carry a small footer line).","perks":["Unlimited HACCP plans, complaints, CAPAs and audits","Complaint trend report: by month, category, product and cause, with complaints per 10,000 units","All 7 audit checklists, incl. supplier approval, internal audit and temperature control","All 10 blank record forms and the allergen matrix","Print the full food safety binder in one go, without the footer line","CSV export of every register and a JSON backup"],"plans":[{"offer":"st-hazelsprig-pro-monthly","label":"Monthly","price":"$19","per":"/month","note":"Cancel any time"},{"offer":"st-hazelsprig-pro-yearly","label":"Yearly","price":"$190","per":"/year","note":"2 months free"},{"offer":"st-hazelsprig-pro-lifetime","label":"Lifetime","price":"$290","per":" once","note":"Pay once, keep forever","best":true}]};</script>
|
| 168 |
+
<script src="pro.js"></script>
|
| 169 |
+
<script src="lib/core.js"></script>
|
| 170 |
+
<script src="lib/content.js"></script>
|
| 171 |
+
<script src="app.js"></script>
|
| 172 |
+
</body>
|
| 173 |
+
</html>
|
hazelsprig/lib/content.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Hazelsprig starter content: example HACCP plans, audit checklists and blank record forms.
|
| 2 |
+
* Examples are starting points to edit, not approved plans. Temperatures quoted are from the FDA Food Code 2022
|
| 3 |
+
* (US); local codes differ, so users confirm limits with their regulator. (c) 2026 CyberMax. All rights reserved. */
|
| 4 |
+
(function (root, factory) {
|
| 5 |
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
| 6 |
+
else root.HZC = factory();
|
| 7 |
+
})(typeof self !== "undefined" ? self : this, function () {
|
| 8 |
+
"use strict";
|
| 9 |
+
|
| 10 |
+
function hz(type, hazard, cause, sig, control, ans, ccp) {
|
| 11 |
+
var h = { type: type, hazard: hazard, cause: cause, likely: sig === "yes" ? "Likely" : "Unlikely", severe: sig === "yes" ? "High" : "Low", significant: sig, control: control,
|
| 12 |
+
q1: ans[0] || "", q2: ans[1] || "", q3: ans[2] || "", q4: ans[3] || "", limit: "", monitor: "", freq: "", who: "", correct: "", verify: "", records: "" };
|
| 13 |
+
if (ccp) for (var k in ccp) h[k] = ccp[k];
|
| 14 |
+
return h;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
var EXAMPLES = {
|
| 18 |
+
poultry: {
|
| 19 |
+
name: "Grilled chicken plate (cook and serve)",
|
| 20 |
+
product: "Boneless chicken thighs, marinated, grilled to order and served hot with sides. Raw chicken is delivered chilled.",
|
| 21 |
+
use: "Eaten hot on site by the general public, including children and older adults.",
|
| 22 |
+
consumers: "General public", storage: "Raw chicken chilled at 41°F (5°C) or below; cooked chicken served at once or hot held.",
|
| 23 |
+
team: "Owner (HACCP lead), head chef, shift lead", scope: "From receiving raw chicken to service.",
|
| 24 |
+
steps: [
|
| 25 |
+
{ name: "Receive raw chicken", hazards: [
|
| 26 |
+
hz("B", "Growth of Salmonella / Campylobacter", "Delivered above safe temperature", "yes", "Check delivery temperature; reject if above limit", ["yes", "no", "yes", "yes"]),
|
| 27 |
+
hz("P", "Packaging fragments", "Damaged packaging", "no", "Visual check on receipt", []),
|
| 28 |
+
] },
|
| 29 |
+
{ name: "Cold storage", hazards: [
|
| 30 |
+
hz("B", "Pathogen growth", "Fridge running warm", "yes", "Fridge at 41°F (5°C) or below, checked twice a day", ["yes", "no", "yes", "yes"]),
|
| 31 |
+
hz("B", "Cross-contamination of ready-to-eat food", "Raw chicken stored above ready-to-eat food", "yes", "Raw poultry on the bottom shelf, covered", ["yes", "no", "yes", "no"],
|
| 32 |
+
{ limit: "Raw poultry stored below and apart from all ready-to-eat food, covered", monitor: "Visual check of shelving", freq: "Opening and closing checks", who: "Shift lead",
|
| 33 |
+
correct: "Move raw poultry to the bottom shelf; discard any ready-to-eat food it may have dripped on", verify: "Manager reviews the check sheet weekly", records: "Daily opening/closing checklist" }),
|
| 34 |
+
] },
|
| 35 |
+
{ name: "Cook", hazards: [
|
| 36 |
+
hz("B", "Survival of Salmonella / Campylobacter", "Undercooking", "yes", "Cook to the required internal temperature", ["yes", "yes"],
|
| 37 |
+
{ limit: "Internal temperature of 165°F (74°C) or above, instantaneous (FDA Food Code 2022, 3-401.11, poultry)", monitor: "Calibrated probe thermometer in the thickest part",
|
| 38 |
+
freq: "Every batch", who: "Grill cook", correct: "Keep cooking until 165°F is reached; re-check; record the corrective action",
|
| 39 |
+
verify: "Thermometer calibration check weekly (ice point 32°F / 0°C); manager reviews cook log weekly", records: "Cooking temperature log; calibration log" }),
|
| 40 |
+
] },
|
| 41 |
+
{ name: "Hot hold", hazards: [
|
| 42 |
+
hz("B", "Growth of spore-formers (e.g. Clostridium perfringens)", "Held too cool", "yes", "Hot hold at 135°F (57°C) or above", ["yes", "no", "yes", "no"],
|
| 43 |
+
{ limit: "135°F (57°C) or above (FDA Food Code 2022, 3-501.16)", monitor: "Probe thermometer", freq: "Every 2 hours", who: "Shift lead",
|
| 44 |
+
correct: "If below 135°F for under 2 hours (a common house rule; check your local code): reheat to 165°F within 2 hours; otherwise discard", verify: "Manager reviews the holding log weekly", records: "Hot holding log" }),
|
| 45 |
+
] },
|
| 46 |
+
{ name: "Serve", hazards: [
|
| 47 |
+
hz("A", "Undeclared allergen (e.g. soy, sesame or wheat in marinade)", "Staff unaware of marinade ingredients", "yes", "Allergen matrix at the pass; staff trained to check it", ["yes", "no", "yes", "no"],
|
| 48 |
+
{ limit: "Every allergen in the dish is on the allergen matrix and told to the customer on request", monitor: "Check the matrix when a customer asks; matrix updated on any recipe change",
|
| 49 |
+
freq: "Every allergen request; matrix reviewed monthly", who: "Server and shift lead", correct: "Stop service of the dish to that customer; remake with confirmed ingredients; update the matrix",
|
| 50 |
+
verify: "Monthly matrix review against recipes and labels", records: "Allergen matrix; training record" }),
|
| 51 |
+
] },
|
| 52 |
+
],
|
| 53 |
+
},
|
| 54 |
+
deli: {
|
| 55 |
+
name: "Cold deli sandwiches (prepared ahead)",
|
| 56 |
+
product: "Sandwiches made with sliced cooked meats, cheese, salad and sauces; wrapped and kept chilled for sale the same day.",
|
| 57 |
+
use: "Eaten cold, on site or taken away, by the general public.",
|
| 58 |
+
consumers: "General public", storage: "Chilled at 41°F (5°C) or below; sold the day they are made.",
|
| 59 |
+
team: "Owner (HACCP lead), kitchen supervisor", scope: "From receiving ingredients to sale.",
|
| 60 |
+
steps: [
|
| 61 |
+
{ name: "Receive chilled ingredients", hazards: [
|
| 62 |
+
hz("B", "Growth of Listeria monocytogenes", "Delivered above safe temperature", "yes", "Delivery temperature check; reject above limit", ["yes", "no", "yes", "yes"]),
|
| 63 |
+
] },
|
| 64 |
+
{ name: "Wash salad", hazards: [
|
| 65 |
+
hz("B", "Pathogens on raw produce", "Soil and handling", "yes", "Wash in clean running water", ["yes", "no", "yes", "no"],
|
| 66 |
+
{ limit: "All salad washed in clean running water before use (or bought ready-washed)", monitor: "Visual check", freq: "Every batch", who: "Prep cook",
|
| 67 |
+
correct: "Re-wash before use", verify: "Supervisor spot check daily", records: "Prep checklist" }),
|
| 68 |
+
] },
|
| 69 |
+
{ name: "Assemble", hazards: [
|
| 70 |
+
hz("B", "Contamination from hands and surfaces", "Bare-hand contact, dirty boards", "yes", "Gloves or utensils; clean and sanitised surfaces", ["yes", "no", "yes", "no"],
|
| 71 |
+
{ limit: "No bare-hand contact with ready-to-eat food (FDA Food Code 2022, 3-301.11); surfaces sanitised before each session", monitor: "Supervisor observation",
|
| 72 |
+
freq: "Every session", who: "Kitchen supervisor", correct: "Stop; rewash hands, change gloves, sanitise; discard affected sandwiches", verify: "Weekly review of checklist",
|
| 73 |
+
records: "Cleaning schedule; prep checklist" }),
|
| 74 |
+
hz("A", "Allergen cross-contact (sesame, mustard, wheat, milk, egg)", "Shared boards and utensils", "yes", "Separate utensils, correct labels", ["yes", "no", "yes", "no"],
|
| 75 |
+
{ limit: "Each sandwich label lists every allergen in its recipe", monitor: "Label check against recipe card", freq: "Every batch", who: "Kitchen supervisor",
|
| 76 |
+
correct: "Relabel or hold product; check recipe card", verify: "Weekly label audit", records: "Label check log" }),
|
| 77 |
+
] },
|
| 78 |
+
{ name: "Chilled display", hazards: [
|
| 79 |
+
hz("B", "Growth of Listeria", "Display above 41°F", "yes", "Cold hold at 41°F (5°C) or below", ["yes", "no", "yes", "no"],
|
| 80 |
+
{ limit: "41°F (5°C) or below (FDA Food Code 2022, 3-501.16)", monitor: "Display thermometer and probe check", freq: "Every 4 hours", who: "Counter staff",
|
| 81 |
+
correct: "Move product to a working fridge; discard if above 41°F for more than 4 hours or time unknown", verify: "Manager reviews log weekly", records: "Fridge/display temperature log" }),
|
| 82 |
+
] },
|
| 83 |
+
],
|
| 84 |
+
},
|
| 85 |
+
bakery: {
|
| 86 |
+
name: "Cookies and cakes (small bakery)",
|
| 87 |
+
product: "Baked cookies and sponge cakes; some contain nuts. Sold pre-packed with labels and loose at the counter.",
|
| 88 |
+
use: "Eaten as purchased by the general public, including people with food allergies.",
|
| 89 |
+
consumers: "General public", storage: "Ambient, in sealed containers; shelf life set by the bakery.",
|
| 90 |
+
team: "Owner (HACCP lead), head baker", scope: "From receiving ingredients to sale and dispatch.",
|
| 91 |
+
steps: [
|
| 92 |
+
{ name: "Receive and store dry ingredients", hazards: [
|
| 93 |
+
hz("P", "Pests or foreign material", "Damaged sacks, poor storage", "yes", "Inspect on receipt; sealed bins off the floor", ["yes", "no", "yes", "no"],
|
| 94 |
+
{ limit: "No damaged packaging or signs of pests accepted; stored in sealed bins", monitor: "Visual check", freq: "Every delivery", who: "Head baker",
|
| 95 |
+
correct: "Reject the delivery or discard affected stock; inform the supplier", verify: "Monthly storage audit", records: "Receiving log" }),
|
| 96 |
+
] },
|
| 97 |
+
{ name: "Weigh and mix", hazards: [
|
| 98 |
+
hz("A", "Nut cross-contact into nut-free products", "Shared equipment", "yes", "Make nut-free products first; clean between runs", ["yes", "no", "yes", "no"],
|
| 99 |
+
{ limit: "Nut-free products made before nut products, or after a full clean that is signed off", monitor: "Production order and clean-down sign-off",
|
| 100 |
+
freq: "Every production day", who: "Head baker", correct: "Hold the batch; label as may contain nuts or discard", verify: "Weekly review", records: "Production and cleaning record" }),
|
| 101 |
+
] },
|
| 102 |
+
{ name: "Bake", hazards: [
|
| 103 |
+
hz("B", "Survival of pathogens from raw egg and flour", "Underbaking", "no", "Bake to recipe time and temperature; product baked through", []),
|
| 104 |
+
] },
|
| 105 |
+
{ name: "Label and pack", hazards: [
|
| 106 |
+
hz("A", "Undeclared allergen on label", "Wrong label or recipe change not updated", "yes", "Label checked against recipe", ["yes", "yes"],
|
| 107 |
+
{ limit: "Label lists every allergen in the recipe (US: the 9 major food allergens)", monitor: "Check label against the current recipe card",
|
| 108 |
+
freq: "Start of every batch and on any label roll change", who: "Head baker", correct: "Stop packing; relabel or withdraw affected stock",
|
| 109 |
+
verify: "Owner checks one label per product weekly", records: "Label check log" }),
|
| 110 |
+
] },
|
| 111 |
+
],
|
| 112 |
+
},
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
var CHECKLISTS = [
|
| 116 |
+
{ id: "gmp", name: "GMP walk-through", free: true, items: [
|
| 117 |
+
"Staff wash hands at a stocked hand sink (soap, paper towels) before handling food",
|
| 118 |
+
"Hand sinks are used only for handwashing and are not blocked",
|
| 119 |
+
"Staff wear clean clothes, hair restraints and no jewellery other than a plain band",
|
| 120 |
+
"Ill staff (vomiting, diarrhoea, jaundice) are excluded and this rule is known",
|
| 121 |
+
"Food is stored at least 6 in (15 cm) off the floor and covered",
|
| 122 |
+
"Raw meat, poultry and fish are stored below ready-to-eat food",
|
| 123 |
+
"Chemicals are labelled and stored away from food and food-contact surfaces",
|
| 124 |
+
"Food-contact surfaces are clean and in good repair",
|
| 125 |
+
"Thermometers are available and calibrated",
|
| 126 |
+
"Waste bins are lidded and emptied regularly",
|
| 127 |
+
"No signs of pests (droppings, gnawing, live insects)",
|
| 128 |
+
"Doors, windows and screens keep pests out",
|
| 129 |
+
] },
|
| 130 |
+
{ id: "ssop", name: "Sanitation (SSOP) check", free: true, items: [
|
| 131 |
+
"The cleaning schedule is posted and signed off for today",
|
| 132 |
+
"Sanitiser is mixed to the label concentration and checked with test strips",
|
| 133 |
+
"Food-contact surfaces are cleaned and sanitised after each task and at least every 4 hours in continuous use",
|
| 134 |
+
"Cutting boards and utensils are washed, rinsed and sanitised (or dish machine reaches its rated temperature)",
|
| 135 |
+
"Cloths are stored in sanitiser between uses",
|
| 136 |
+
"Floors, drains and walls in food areas are clean",
|
| 137 |
+
"Equipment is taken apart for cleaning where the manufacturer says so",
|
| 138 |
+
"Cleaning chemicals are approved for food areas and safety data sheets are on site",
|
| 139 |
+
"Ice machines and drinks equipment are on the cleaning schedule",
|
| 140 |
+
"Corrective actions for failed cleaning checks are recorded",
|
| 141 |
+
] },
|
| 142 |
+
{ id: "allergen", name: "Allergen control", free: true, items: [
|
| 143 |
+
"An allergen matrix exists for every menu item or product and matches current recipes",
|
| 144 |
+
"Staff know where the matrix is and how to answer an allergen question",
|
| 145 |
+
"Allergen-containing ingredients are labelled and stored to prevent spills onto other food",
|
| 146 |
+
"Separate or cleaned utensils and boards are used for allergen-free orders",
|
| 147 |
+
"Labels on pre-packed food list every allergen in the recipe",
|
| 148 |
+
"Recipe or supplier changes trigger a matrix and label review",
|
| 149 |
+
"Incoming ingredient labels are checked for new allergens or 'may contain' statements",
|
| 150 |
+
"Allergen training is recorded for all food handlers",
|
| 151 |
+
] },
|
| 152 |
+
{ id: "supplier", name: "Supplier approval", free: false, items: [
|
| 153 |
+
"Every food supplier is on an approved supplier list",
|
| 154 |
+
"Each supplier's food safety evidence is on file (licence, inspection report, certificate or questionnaire)",
|
| 155 |
+
"Specifications or product sheets are held for key ingredients, including allergens",
|
| 156 |
+
"Delivery temperature and condition checks are recorded",
|
| 157 |
+
"Rejected deliveries and supplier issues are logged",
|
| 158 |
+
"Suppliers are reviewed at least yearly, or after a significant issue",
|
| 159 |
+
"A process exists to approve a new or emergency supplier",
|
| 160 |
+
"Supplier contact details for recalls are current",
|
| 161 |
+
] },
|
| 162 |
+
{ id: "internal", name: "Internal food safety audit", free: false, items: [
|
| 163 |
+
"The HACCP plan has been reviewed in the last 12 months and after any menu or process change",
|
| 164 |
+
"Monitoring records for every CCP are complete, signed and checked",
|
| 165 |
+
"Corrective actions are recorded for every deviation",
|
| 166 |
+
"Verification activities (thermometer calibration, record review) are done on schedule",
|
| 167 |
+
"Training records exist for every food handler",
|
| 168 |
+
"The customer complaint register is up to date and trends are reviewed",
|
| 169 |
+
"Open CAPAs have owners and due dates; overdue ones are escalated",
|
| 170 |
+
"The recall / withdrawal procedure has been tested in the last 12 months",
|
| 171 |
+
"Pest control visits and findings are recorded",
|
| 172 |
+
"Last inspection findings have been closed out",
|
| 173 |
+
] },
|
| 174 |
+
{ id: "temp", name: "Temperature control", free: false, items: [
|
| 175 |
+
"Fridges hold food at 41°F (5°C) or below",
|
| 176 |
+
"Freezers keep food frozen solid",
|
| 177 |
+
"Hot holding keeps food at 135°F (57°C) or above",
|
| 178 |
+
"Cooked food is cooled from 135°F to 70°F within 2 hours and to 41°F within a total of 6 hours",
|
| 179 |
+
"Food reheated for hot holding reaches 165°F (74°C) within 2 hours",
|
| 180 |
+
"Frozen food is thawed in the fridge, under cold running water, or as part of cooking",
|
| 181 |
+
"Probe thermometers are cleaned and sanitised between uses",
|
| 182 |
+
"Temperature logs are complete for every shift",
|
| 183 |
+
] },
|
| 184 |
+
{ id: "complaints", name: "Complaint programme review", free: false, items: [
|
| 185 |
+
"There is a written complaint procedure and staff know how to take a complaint",
|
| 186 |
+
"Every complaint is logged with date, product, batch/lot and details",
|
| 187 |
+
"Samples and packaging from complaints are kept where possible",
|
| 188 |
+
"Food safety complaints (illness, allergen, foreign material) are escalated the same day",
|
| 189 |
+
"Each complaint gets a root-cause investigation proportionate to its severity",
|
| 190 |
+
"Corrective actions are linked to complaints and verified",
|
| 191 |
+
"Customers receive a response within the target time",
|
| 192 |
+
"Complaint trends are reviewed at least monthly",
|
| 193 |
+
] },
|
| 194 |
+
];
|
| 195 |
+
|
| 196 |
+
var US_ALLERGENS = ["Milk", "Eggs", "Fish", "Crustacean shellfish", "Tree nuts", "Peanuts", "Wheat", "Soybeans", "Sesame"];
|
| 197 |
+
var EU_ALLERGENS = ["Celery", "Cereals containing gluten", "Crustaceans", "Eggs", "Fish", "Lupin", "Milk", "Molluscs", "Mustard", "Tree nuts", "Peanuts", "Sesame", "Soybeans", "Sulphur dioxide and sulphites"];
|
| 198 |
+
|
| 199 |
+
// Blank printable record forms. cols = column headings; rows = blank lines per page.
|
| 200 |
+
var FORMS = [
|
| 201 |
+
{ id: "fridge", name: "Fridge and freezer temperature log", free: true, note: "Target: fridges 41°F (5°C) or below. Record twice a day; write the action taken for any reading out of range.",
|
| 202 |
+
cols: ["Date", "Time", "Unit", "Reading", "In range?", "Action taken", "Initials"], rows: 22 },
|
| 203 |
+
{ id: "complaint", name: "Customer complaint form", free: true, kind: "complaint" },
|
| 204 |
+
{ id: "cook", name: "Cooking temperature log", free: false, note: "Record the internal temperature of each batch. Poultry 165°F (74°C); check your local code for other foods.",
|
| 205 |
+
cols: ["Date", "Time", "Food / batch", "Target", "Reading", "Action if below target", "Initials"], rows: 22 },
|
| 206 |
+
{ id: "cool", name: "Cooling log (two-stage)", free: false, note: "135°F to 70°F within 2 hours, then to 41°F within a total of 6 hours (FDA Food Code 2022). Reheat or discard if a stage is missed.",
|
| 207 |
+
cols: ["Date", "Food", "Start time / temp", "+2 h temp", "+6 h temp", "Action", "Initials"], rows: 20 },
|
| 208 |
+
{ id: "hold", name: "Hot and cold holding log", free: false, note: "Hot holding 135°F (57°C) or above; cold holding 41°F (5°C) or below. Check at least every 4 hours.",
|
| 209 |
+
cols: ["Date", "Time", "Food", "Hot / cold", "Reading", "Action", "Initials"], rows: 22 },
|
| 210 |
+
{ id: "receive", name: "Delivery receiving log", free: false, note: "Chilled deliveries 41°F (5°C) or below. Check packaging, dates and labels; reject anything unsafe.",
|
| 211 |
+
cols: ["Date", "Supplier", "Product", "Temp", "Packaging / date OK?", "Accepted / rejected", "Initials"], rows: 22 },
|
| 212 |
+
{ id: "clean", name: "Cleaning schedule", free: false, note: "Tick when done. Write the action taken if a task is missed.",
|
| 213 |
+
cols: ["Area / item", "Method / chemical", "Frequency", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], rows: 18 },
|
| 214 |
+
{ id: "capa", name: "CAPA form", free: false, kind: "capa" },
|
| 215 |
+
{ id: "allergen", name: "Allergen matrix", free: false, kind: "allergen" },
|
| 216 |
+
{ id: "training", name: "Food safety training record", free: false, note: "One line per person per topic.",
|
| 217 |
+
cols: ["Name / role", "Topic", "Date", "Trainer", "Signature"], rows: 22 },
|
| 218 |
+
];
|
| 219 |
+
|
| 220 |
+
return { EXAMPLES: EXAMPLES, CHECKLISTS: CHECKLISTS, FORMS: FORMS, US_ALLERGENS: US_ALLERGENS, EU_ALLERGENS: EU_ALLERGENS };
|
| 221 |
+
});
|
hazelsprig/lib/core.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Hazelsprig core: HACCP plan logic, customer complaint register, CAPA log and audit checklists.
|
| 2 |
+
* Pure functions only (no DOM), so the same file runs in the browser and in node tests.
|
| 3 |
+
* Hazelsprig is a record-keeping and document tool. It does not replace a qualified food safety professional,
|
| 4 |
+
* your regulator or your certification body. (c) 2026 CyberMax. All rights reserved. */
|
| 5 |
+
(function (root, factory) {
|
| 6 |
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
| 7 |
+
else root.HZ = factory();
|
| 8 |
+
})(typeof self !== "undefined" ? self : this, function () {
|
| 9 |
+
"use strict";
|
| 10 |
+
|
| 11 |
+
var FREE = { plans: 1, complaints: 15, capas: 5, audits: 3 };
|
| 12 |
+
|
| 13 |
+
var HAZARD_TYPES = [
|
| 14 |
+
{ id: "B", label: "Biological" },
|
| 15 |
+
{ id: "C", label: "Chemical" },
|
| 16 |
+
{ id: "P", label: "Physical" },
|
| 17 |
+
{ id: "A", label: "Allergen" },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
// Codex-style CCP decision tree (CXC 1-1969, the four classic questions).
|
| 21 |
+
// Q1 Do preventive control measures exist for this hazard at this step? no -> not a CCP (flag: modify the step if control is needed for safety)
|
| 22 |
+
// Q2 Is the step specifically designed to eliminate or reduce the hazard to an acceptable level? yes -> CCP
|
| 23 |
+
// Q3 Could contamination occur, or increase to unacceptable levels, at this step? no -> not a CCP
|
| 24 |
+
// Q4 Will a later step eliminate the hazard or reduce it to an acceptable level? yes -> not a CCP; no -> CCP
|
| 25 |
+
function ccpDecision(a) {
|
| 26 |
+
a = a || {};
|
| 27 |
+
if (a.q1 === "no") return { ccp: false, done: true, at: "Q1", note: "Not a CCP. If control here is needed for safety, change the step, process or product so a control measure exists." };
|
| 28 |
+
if (a.q1 !== "yes") return { ccp: false, done: false, at: "Q1", note: "Answer Q1." };
|
| 29 |
+
if (a.q2 === "yes") return { ccp: true, done: true, at: "Q2", note: "CCP: this step is designed to eliminate or reduce the hazard." };
|
| 30 |
+
if (a.q2 !== "no") return { ccp: false, done: false, at: "Q2", note: "Answer Q2." };
|
| 31 |
+
if (a.q3 === "no") return { ccp: false, done: true, at: "Q3", note: "Not a CCP: the hazard cannot reach unacceptable levels at this step." };
|
| 32 |
+
if (a.q3 !== "yes") return { ccp: false, done: false, at: "Q3", note: "Answer Q3." };
|
| 33 |
+
if (a.q4 === "yes") return { ccp: false, done: true, at: "Q4", note: "Not a CCP: a later step controls this hazard. Make sure that later step is in your plan." };
|
| 34 |
+
if (a.q4 === "no") return { ccp: true, done: true, at: "Q4", note: "CCP: no later step controls this hazard." };
|
| 35 |
+
return { ccp: false, done: false, at: "Q4", note: "Answer Q4." };
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function uid(prefix) { return (prefix || "x") + Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-4); }
|
| 39 |
+
|
| 40 |
+
// ---------- HACCP plan ----------
|
| 41 |
+
function newPlan(name) {
|
| 42 |
+
return { id: uid("p"), name: name || "New HACCP plan", product: "", use: "", consumers: "", storage: "", team: "", scope: "", steps: [], updated: today() };
|
| 43 |
+
}
|
| 44 |
+
function newStep(name) { return { id: uid("s"), name: name || "", hazards: [] }; }
|
| 45 |
+
function newHazard() {
|
| 46 |
+
return { id: uid("h"), type: "B", hazard: "", cause: "", likely: "", severe: "", significant: "", control: "",
|
| 47 |
+
q1: "", q2: "", q3: "", q4: "", limit: "", monitor: "", freq: "", who: "", correct: "", verify: "", records: "" };
|
| 48 |
+
}
|
| 49 |
+
/** Every CCP of a plan with its numbering (CCP 1, CCP 2...) in flow order. */
|
| 50 |
+
function ccps(plan) {
|
| 51 |
+
var out = [], n = 0;
|
| 52 |
+
(plan.steps || []).forEach(function (s, si) {
|
| 53 |
+
(s.hazards || []).forEach(function (h) {
|
| 54 |
+
if (h.significant !== "yes") return;
|
| 55 |
+
var d = ccpDecision(h);
|
| 56 |
+
if (d.ccp) { n++; out.push({ n: n, label: "CCP " + n, step: s, stepNo: si + 1, hazard: h }); }
|
| 57 |
+
});
|
| 58 |
+
});
|
| 59 |
+
return out;
|
| 60 |
+
}
|
| 61 |
+
/** What is still missing before the plan reads as complete (shown as a checklist, not a pass/fail verdict). */
|
| 62 |
+
function planGaps(plan) {
|
| 63 |
+
var g = [];
|
| 64 |
+
if (!plan.product) g.push("Describe the product (what it is, ingredients, packaging).");
|
| 65 |
+
if (!plan.use) g.push("State the intended use and who eats it (including vulnerable groups).");
|
| 66 |
+
if (!plan.team) g.push("Name the HACCP team (roles are enough).");
|
| 67 |
+
if (!(plan.steps || []).length) g.push("Add the process flow steps, from receiving to service or dispatch.");
|
| 68 |
+
(plan.steps || []).forEach(function (s, i) {
|
| 69 |
+
if (!(s.hazards || []).length) g.push("Step " + (i + 1) + " (" + (s.name || "unnamed") + "): no hazards analysed yet.");
|
| 70 |
+
(s.hazards || []).forEach(function (h) {
|
| 71 |
+
var nm = "Step " + (i + 1) + " " + (h.hazard || "hazard");
|
| 72 |
+
if (!h.significant) g.push(nm + ": decide whether it is significant.");
|
| 73 |
+
if (h.significant === "yes") {
|
| 74 |
+
var d = ccpDecision(h);
|
| 75 |
+
if (!d.done) g.push(nm + ": finish the CCP decision questions.");
|
| 76 |
+
if (d.ccp) {
|
| 77 |
+
if (!h.limit) g.push(nm + ": set a critical limit.");
|
| 78 |
+
if (!h.monitor || !h.freq) g.push(nm + ": say how and how often it is monitored.");
|
| 79 |
+
if (!h.correct) g.push(nm + ": write the corrective action.");
|
| 80 |
+
if (!h.verify) g.push(nm + ": add verification.");
|
| 81 |
+
if (!h.records) g.push(nm + ": name the record kept.");
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
return g;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// ---------- customer complaints ----------
|
| 90 |
+
var COMPLAINT_CATS = [
|
| 91 |
+
{ id: "foreign", label: "Foreign material" },
|
| 92 |
+
{ id: "allergen", label: "Allergen / undeclared allergen" },
|
| 93 |
+
{ id: "illness", label: "Illness or injury claim" },
|
| 94 |
+
{ id: "spoilage", label: "Spoilage, mould or off product" },
|
| 95 |
+
{ id: "quality", label: "Taste, texture or appearance" },
|
| 96 |
+
{ id: "label", label: "Packaging or labelling" },
|
| 97 |
+
{ id: "service", label: "Service, delivery or order" },
|
| 98 |
+
{ id: "other", label: "Other" },
|
| 99 |
+
];
|
| 100 |
+
var CAUSE_CATS = ["People", "Method", "Machine", "Material", "Measurement", "Environment"];
|
| 101 |
+
var CRITICAL = { allergen: 1, illness: 1 };
|
| 102 |
+
|
| 103 |
+
function newComplaint(seq) {
|
| 104 |
+
return { id: uid("c"), ref: "C-" + String(seq || 1).padStart(4, "0"), date: today(), channel: "In person", customer: "", contact: "",
|
| 105 |
+
product: "", lot: "", category: "quality", severity: "", desc: "", sample: "", investigation: "", whys: ["", "", "", "", ""], causeCat: "",
|
| 106 |
+
rootCause: "", action: "", capa: "", response: "", responded: "", status: "Open", closed: "" };
|
| 107 |
+
}
|
| 108 |
+
/** Suggested severity: allergen and illness claims are critical; foreign material is major unless it is hard or sharp. */
|
| 109 |
+
function suggestSeverity(c) {
|
| 110 |
+
if (CRITICAL[c.category]) return "Critical";
|
| 111 |
+
if (c.category === "foreign") return /glass|metal|wire|plastic shard|sharp|hard|bone|stone|blade|needle/i.test(c.desc || "") ? "Critical" : "Major";
|
| 112 |
+
if (c.category === "spoilage" || c.category === "label") return "Major";
|
| 113 |
+
return "Minor";
|
| 114 |
+
}
|
| 115 |
+
/** Escalation prompt for possible food safety events. Wording is guidance to review, never a legal determination. */
|
| 116 |
+
function escalation(c) {
|
| 117 |
+
var sev = c.severity || suggestSeverity(c);
|
| 118 |
+
if (sev !== "Critical") return null;
|
| 119 |
+
return "Possible food safety event. Hold any affected product you still have, keep the sample, review your recall plan, and check whether you must report it to your regulator (for example, FDA-registered facilities use the Reportable Food Registry when there is a reasonable probability of serious harm). Decide with your qualified food safety lead.";
|
| 120 |
+
}
|
| 121 |
+
function daysBetween(a, b) {
|
| 122 |
+
var x = Date.parse(a), y = Date.parse(b);
|
| 123 |
+
if (isNaN(x) || isNaN(y)) return null;
|
| 124 |
+
return Math.round((y - x) / 864e5);
|
| 125 |
+
}
|
| 126 |
+
/** Trend report over complaints: by month (last `months`), category, product, severity, cause, open ageing and time to close.
|
| 127 |
+
* volumes: optional {"2026-09": 12000, ...} units produced or sold that month -> complaints per 10,000 units. */
|
| 128 |
+
function trends(list, opts) {
|
| 129 |
+
opts = opts || {};
|
| 130 |
+
var now = opts.today || today(), months = opts.months || 12, vol = opts.volumes || {};
|
| 131 |
+
var mk = [], d = new Date(now + "T00:00:00Z");
|
| 132 |
+
for (var i = months - 1; i >= 0; i--) {
|
| 133 |
+
var m = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - i, 1));
|
| 134 |
+
mk.push(m.toISOString().slice(0, 7));
|
| 135 |
+
}
|
| 136 |
+
var byMonth = {}; mk.forEach(function (k) { byMonth[k] = 0; });
|
| 137 |
+
var byCat = {}, byProduct = {}, bySev = { Critical: 0, Major: 0, Minor: 0 }, byCause = {}, open = [], closeDays = [];
|
| 138 |
+
(list || []).forEach(function (c) {
|
| 139 |
+
var k = String(c.date || "").slice(0, 7);
|
| 140 |
+
if (k in byMonth) byMonth[k]++;
|
| 141 |
+
byCat[c.category] = (byCat[c.category] || 0) + 1;
|
| 142 |
+
var p = (c.product || "Not stated").trim() || "Not stated";
|
| 143 |
+
byProduct[p] = (byProduct[p] || 0) + 1;
|
| 144 |
+
var s = c.severity || suggestSeverity(c);
|
| 145 |
+
bySev[s] = (bySev[s] || 0) + 1;
|
| 146 |
+
if (c.causeCat) byCause[c.causeCat] = (byCause[c.causeCat] || 0) + 1;
|
| 147 |
+
if (c.status !== "Closed") open.push({ ref: c.ref, age: daysBetween(c.date, now), severity: s, category: c.category });
|
| 148 |
+
else if (c.closed) { var dd = daysBetween(c.date, c.closed); if (dd !== null && dd >= 0) closeDays.push(dd); }
|
| 149 |
+
});
|
| 150 |
+
var rows = mk.map(function (k) {
|
| 151 |
+
var v = Number(vol[k]) || 0;
|
| 152 |
+
return { month: k, count: byMonth[k], volume: v || null, per10k: v ? Math.round(byMonth[k] / v * 1e4 * 100) / 100 : null };
|
| 153 |
+
});
|
| 154 |
+
// Pareto by category: sorted counts with cumulative share.
|
| 155 |
+
var total = (list || []).length, cum = 0;
|
| 156 |
+
var pareto = Object.keys(byCat).map(function (k) { return { id: k, label: catLabel(k), count: byCat[k] }; })
|
| 157 |
+
.sort(function (a, b) { return b.count - a.count || a.label.localeCompare(b.label); })
|
| 158 |
+
.map(function (r) { cum += r.count; r.share = total ? Math.round(r.count / total * 1000) / 10 : 0; r.cum = total ? Math.round(cum / total * 1000) / 10 : 0; return r; });
|
| 159 |
+
var last3 = rows.slice(-3).reduce(function (a, r) { return a + r.count; }, 0), prev3 = rows.slice(-6, -3).reduce(function (a, r) { return a + r.count; }, 0);
|
| 160 |
+
var products = Object.keys(byProduct).map(function (k) { return { product: k, count: byProduct[k] }; }).sort(function (a, b) { return b.count - a.count || a.product.localeCompare(b.product); });
|
| 161 |
+
open.sort(function (a, b) { return (b.age || 0) - (a.age || 0); });
|
| 162 |
+
return {
|
| 163 |
+
total: total, months: rows, pareto: pareto, products: products, severity: bySev, causes: byCause, open: open,
|
| 164 |
+
avgClose: closeDays.length ? Math.round(closeDays.reduce(function (a, b) { return a + b; }, 0) / closeDays.length * 10) / 10 : null,
|
| 165 |
+
last3: last3, prev3: prev3, direction: last3 > prev3 ? "up" : last3 < prev3 ? "down" : "flat",
|
| 166 |
+
};
|
| 167 |
+
}
|
| 168 |
+
function catLabel(id) { for (var i = 0; i < COMPLAINT_CATS.length; i++) if (COMPLAINT_CATS[i].id === id) return COMPLAINT_CATS[i].label; return id || "Other"; }
|
| 169 |
+
|
| 170 |
+
// ---------- CAPA ----------
|
| 171 |
+
var CAPA_SOURCES = ["Customer complaint", "Internal audit", "Inspection or external audit", "Monitoring deviation", "Supplier issue", "Other"];
|
| 172 |
+
var CAPA_STATUS = ["Open", "In progress", "Verify", "Closed"];
|
| 173 |
+
function newCapa(seq) {
|
| 174 |
+
return { id: uid("a"), ref: "CAPA-" + String(seq || 1).padStart(4, "0"), opened: today(), source: "Customer complaint", link: "", problem: "",
|
| 175 |
+
rootCause: "", correction: "", corrective: "", preventive: "", owner: "", due: "", status: "Open", verifyDate: "", effective: "", closed: "" };
|
| 176 |
+
}
|
| 177 |
+
function capaState(a, now) {
|
| 178 |
+
now = now || today();
|
| 179 |
+
if (a.status === "Closed") return "closed";
|
| 180 |
+
if (a.due && a.due < now) return "overdue";
|
| 181 |
+
if (a.due && daysBetween(now, a.due) <= 7) return "due-soon";
|
| 182 |
+
return "open";
|
| 183 |
+
}
|
| 184 |
+
/** A CAPA can be closed only once its effectiveness has been checked and recorded. */
|
| 185 |
+
function canClose(a) { return !!(a.corrective && a.verifyDate && a.effective); }
|
| 186 |
+
|
| 187 |
+
// ---------- audits ----------
|
| 188 |
+
/** Score an audit run: yes = 1, no = 0, n/a excluded. Returns %, counts and the failed items (findings). */
|
| 189 |
+
function auditScore(items, answers) {
|
| 190 |
+
var yes = 0, no = 0, na = 0, open = 0, findings = [];
|
| 191 |
+
items.forEach(function (it, i) {
|
| 192 |
+
var a = (answers || {})[i] || {};
|
| 193 |
+
if (a.v === "yes") yes++;
|
| 194 |
+
else if (a.v === "no") { no++; findings.push({ i: i, text: it, note: a.note || "" }); }
|
| 195 |
+
else if (a.v === "na") na++;
|
| 196 |
+
else open++;
|
| 197 |
+
});
|
| 198 |
+
var scored = yes + no;
|
| 199 |
+
return { yes: yes, no: no, na: na, open: open, pct: scored ? Math.round(yes / scored * 100) : null, findings: findings, complete: open === 0 };
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ---------- CSV / limits ----------
|
| 203 |
+
function csvCell(v) {
|
| 204 |
+
var s = v === null || v === undefined ? "" : Array.isArray(v) ? v.filter(Boolean).join(" > ") : String(v);
|
| 205 |
+
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; // spreadsheet formula injection guard
|
| 206 |
+
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
| 207 |
+
}
|
| 208 |
+
function toCSV(rows, cols) {
|
| 209 |
+
return [cols.map(function (c) { return csvCell(c.label); }).join(",")].concat(rows.map(function (r) {
|
| 210 |
+
return cols.map(function (c) { return csvCell(typeof c.get === "function" ? c.get(r) : r[c.key]); }).join(",");
|
| 211 |
+
})).join("\r\n") + "\r\n";
|
| 212 |
+
}
|
| 213 |
+
function canAdd(kind, count, pro) { return !!pro || count < FREE[kind]; }
|
| 214 |
+
|
| 215 |
+
function today() { return new Date().toISOString().slice(0, 10); }
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
FREE: FREE, HAZARD_TYPES: HAZARD_TYPES, COMPLAINT_CATS: COMPLAINT_CATS, CAUSE_CATS: CAUSE_CATS, CAPA_SOURCES: CAPA_SOURCES, CAPA_STATUS: CAPA_STATUS,
|
| 219 |
+
ccpDecision: ccpDecision, newPlan: newPlan, newStep: newStep, newHazard: newHazard, ccps: ccps, planGaps: planGaps,
|
| 220 |
+
newComplaint: newComplaint, suggestSeverity: suggestSeverity, escalation: escalation, trends: trends, catLabel: catLabel, daysBetween: daysBetween,
|
| 221 |
+
newCapa: newCapa, capaState: capaState, canClose: canClose, auditScore: auditScore, toCSV: toCSV, csvCell: csvCell, canAdd: canAdd, today: today, uid: uid,
|
| 222 |
+
};
|
| 223 |
+
});
|
hazelsprig/manifest.webmanifest
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Hazelsprig: HACCP plan, complaint log and CAPA",
|
| 3 |
+
"short_name": "Hazelsprig",
|
| 4 |
+
"description": "HACCP plan builder, customer complaint register, CAPA log, audit checklists and printable food safety logs. Free, no sign-up, nothing uploaded, works offline.",
|
| 5 |
+
"start_url": "./?utm_source=pwa",
|
| 6 |
+
"scope": "./",
|
| 7 |
+
"display": "standalone",
|
| 8 |
+
"background_color": "#F6F3EC",
|
| 9 |
+
"theme_color": "#2E6B45",
|
| 10 |
+
"categories": ["business", "food", "productivity"],
|
| 11 |
+
"icons": [
|
| 12 |
+
{"src": "icon.svg", "sizes": "any", "type": "image/svg+xml"},
|
| 13 |
+
{"src": "icon-192.png", "sizes": "192x192", "type": "image/png"},
|
| 14 |
+
{"src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
| 15 |
+
{"src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
| 16 |
+
]
|
| 17 |
+
}
|
hazelsprig/og.png
ADDED
|
Git LFS Details
|
hazelsprig/pro.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* CyberMax Pro unlock with plans, for static web apps (copied into each product's web/ folder; each product stays
|
| 2 |
+
* self-contained). Extends pro.js with several plans (monthly, yearly, one-time) and a re-check for subscriptions.
|
| 3 |
+
* Config before this script:
|
| 4 |
+
* window.CMX_PRO = { id, name, perks: [..], plans: [{ offer, label, price, per, note, best }], free: "one line" }
|
| 5 |
+
* Buy: counted link https://data.cybermaxtools.com/buy/<offer>?s=app-<id> -> Stripe Payment Link.
|
| 6 |
+
* After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/<id>?session_id=..., which checks the
|
| 7 |
+
* Checkout Session (and, for a subscription, that it is still active) and returns here with #cmx-pro=<session id>.
|
| 8 |
+
* The app checks that code once (/unlock/<id>?json=1&session_id=..) and remembers Pro in this browser; it re-checks every
|
| 9 |
+
* 3 days, so a cancelled subscription turns off by itself (one tiny request per buyer every 3 days, none for free users).
|
| 10 |
+
* The same code (shown on the receipt page and in the Pro box) unlocks Pro on another device. No account, no tracking.
|
| 11 |
+
* (c) 2026 CyberMax. All rights reserved. */
|
| 12 |
+
(function () {
|
| 13 |
+
"use strict";
|
| 14 |
+
var C = window.CMX_PRO || {};
|
| 15 |
+
if (!C.id) return;
|
| 16 |
+
var DATA = "https://data.cybermaxtools.com";
|
| 17 |
+
var KEY = "cmx-pro:" + C.id;
|
| 18 |
+
var SID = /^(cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i; // Stripe order code or Gumroad licence key
|
| 19 |
+
var RECHECK = 3 * 864e5;
|
| 20 |
+
var listeners = [];
|
| 21 |
+
|
| 22 |
+
function get() { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { return null; } }
|
| 23 |
+
function set(v) { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch (e) { /* private mode */ } mem = v; }
|
| 24 |
+
var mem = get();
|
| 25 |
+
function isPro() { return !!(mem && mem.code); }
|
| 26 |
+
|
| 27 |
+
function check(code) {
|
| 28 |
+
return fetch(DATA + "/unlock/" + encodeURIComponent(C.id) + "?json=1&session_id=" + encodeURIComponent(code))
|
| 29 |
+
.then(function (r) { return r.json(); })
|
| 30 |
+
.then(function (j) { return j && typeof j.pro === "boolean" ? j.pro : null; })
|
| 31 |
+
.catch(function () { return null; });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function unlock(code) {
|
| 35 |
+
return check(code).then(function (ok) {
|
| 36 |
+
if (ok) { set({ code: code, at: new Date().toISOString(), checked: Date.now() }); apply(); toast("Pro unlocked. Thank you for choosing " + C.name + "!"); }
|
| 37 |
+
else if (ok === false) toast("That code is not an active " + C.name + " Pro order.");
|
| 38 |
+
else toast("Could not reach the unlock check. Try again in a minute.");
|
| 39 |
+
return ok;
|
| 40 |
+
});
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function recheck() {
|
| 44 |
+
if (!isPro() || Date.now() - (mem.checked || 0) < RECHECK) return;
|
| 45 |
+
check(mem.code).then(function (ok) {
|
| 46 |
+
if (ok === false) { set(null); apply(); toast(C.name + " Pro has ended in this browser. Everything free is still here."); }
|
| 47 |
+
else if (ok) set(Object.assign({}, mem, { checked: Date.now() }));
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function toast(msg) {
|
| 52 |
+
var t = document.getElementById("toast");
|
| 53 |
+
if (t) { t.textContent = msg; t.hidden = false; t.classList.add("show"); setTimeout(function () { t.classList.remove("show"); }, 3500); }
|
| 54 |
+
var s = document.getElementById("cmx-pro-msg");
|
| 55 |
+
if (s) s.textContent = msg;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }
|
| 59 |
+
function buyUrl(offer, from) { return DATA + "/buy/" + offer + "?s=app-" + C.id + (from ? "-" + from : ""); }
|
| 60 |
+
|
| 61 |
+
// Convert kit (funnel beacons, real proof, email capture, phone buy bar), loaded from the CyberMax Store; optional:
|
| 62 |
+
// if it can't load (offline), the app and the Pro box work exactly the same.
|
| 63 |
+
var KIT = "https://cybermaxtools.com/store/convert.js", kitQ = [];
|
| 64 |
+
function conv(fn) {
|
| 65 |
+
if (window.CMXConvert) return fn(window.CMXConvert);
|
| 66 |
+
kitQ.push(fn);
|
| 67 |
+
if (kitQ.length > 1 || !navigator.onLine) return;
|
| 68 |
+
var sc = document.createElement("script"); sc.src = KIT; sc.async = true;
|
| 69 |
+
sc.onload = function () { var q = kitQ.splice(0); q.forEach(function (f) { try { f(window.CMXConvert); } catch (e) { /* */ } }); };
|
| 70 |
+
document.head.appendChild(sc);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function box() {
|
| 74 |
+
var el = document.getElementById("cmx-pro");
|
| 75 |
+
if (!el) {
|
| 76 |
+
el = document.createElement("section");
|
| 77 |
+
el.id = "cmx-pro";
|
| 78 |
+
var anchor = document.querySelector("footer.more") || document.querySelector("main footer") || document.querySelector("footer");
|
| 79 |
+
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(el, anchor); else (document.querySelector("main") || document.body).appendChild(el);
|
| 80 |
+
}
|
| 81 |
+
el.className = "cmx-pro";
|
| 82 |
+
return el;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function render() {
|
| 86 |
+
var el = box();
|
| 87 |
+
if (isPro()) {
|
| 88 |
+
el.innerHTML = '<h2>' + esc(C.name) + ' Pro <span class="cmx-pro-badge">on</span></h2><p>Thank you! Pro is on in this browser: ' + C.perks.map(esc).join(" · ") +
|
| 89 |
+
'.</p><p class="cmx-pro-small">Your unlock code (paste it on another device): <code style="user-select:all;word-break:break-all">' + esc(mem.code) +
|
| 90 |
+
'</code></p>' + ((C.plans || []).some(function (p) { return /\//.test(p.per || ""); }) ? '<p class="cmx-pro-small">Monthly or yearly plan? Cancel any time from the link in your Stripe receipt email.</p>' : "");
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
var subs = (C.plans || []).some(function (p) { return /\//.test(p.per || ""); });
|
| 94 |
+
var plans = (C.plans || []).map(function (p) {
|
| 95 |
+
return '<a class="cmx-plan' + (p.best ? " best" : "") + '" href="' + buyUrl(p.offer) + '" rel="nofollow" data-offer="' + esc(p.offer) + '">' +
|
| 96 |
+
(p.best ? "<i>Best value</i>" : "") + "<b>" + esc(p.label) + '</b><span class="cmx-price">' + esc(p.price) + "<small>" + esc(p.per || "") +
|
| 97 |
+
'</small></span><span class="cmx-note">' + esc(p.note || "") + "</span></a>";
|
| 98 |
+
}).join("");
|
| 99 |
+
el.innerHTML = '<h2>' + esc(C.name) + ' Pro</h2>' + (C.free ? '<p class="cmx-pro-small">Free: ' + esc(C.free) + "</p>" : "") +
|
| 100 |
+
"<ul>" + C.perks.map(function (p) { return "<li>" + esc(p) + "</li>"; }).join("") + '</ul><div class="cmx-plans">' + plans +
|
| 101 |
+
'</div><p class="cmx-pro-small">Secure Stripe checkout. Pro turns on the moment you pay; no account needed.' + (subs ? " Subscriptions cancel any time from your receipt email." : " One payment, no subscription.") +
|
| 102 |
+
' 14-day promise: if it doesn\'t work as described, we fix it or refund you in full (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). Questions: <a href="mailto:cybermax.tools@gmail.com">cybermax.tools@gmail.com</a></p><div class="cmx-pro-proof"></div><div class="cmx-pro-cap"></div>' +
|
| 103 |
+
'<details><summary>Already bought Pro?</summary><form id="cmx-pro-form"><label>Unlock code <input id="cmx-pro-code" autocomplete="off" placeholder="cs_live_… or Gumroad licence key"></label> <button class="btn ghost" type="submit">Unlock</button></form></details><p id="cmx-pro-msg" class="cmx-pro-small" aria-live="polite"></p>';
|
| 104 |
+
Array.prototype.forEach.call(el.querySelectorAll("a.cmx-plan"), function (a) { a.addEventListener("click", function () { conv(function (K) { K.event("upgrade-click", C.id); }); }); });
|
| 105 |
+
conv(function (K) {
|
| 106 |
+
K.proof(el.querySelector(".cmx-pro-proof"), C.id);
|
| 107 |
+
K.capture(el.querySelector(".cmx-pro-cap"), { list: "weekly-" + C.id, src: "app-" + C.id, label: "Not ready for Pro? Email me " + C.name + " tips and new features (about once a week)" });
|
| 108 |
+
});
|
| 109 |
+
var f = document.getElementById("cmx-pro-form");
|
| 110 |
+
if (f) f.onsubmit = function (e) { e.preventDefault(); var c = (document.getElementById("cmx-pro-code").value || "").trim(); if (SID.test(c)) unlock(c); else toast("Paste the code from your receipt page (it starts with cs_live_) or your Gumroad licence key."); };
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function apply() {
|
| 114 |
+
document.documentElement.classList.toggle("cmx-pro", isPro());
|
| 115 |
+
render();
|
| 116 |
+
listeners.forEach(function (fn) { try { fn(isPro()); } catch (e) { /* app hook */ } });
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/** Scroll to the Pro box (call when a free user hits a limit); `from` tags the counted buy links. */
|
| 120 |
+
function upsell(reason, from) {
|
| 121 |
+
var el = box();
|
| 122 |
+
if (from) Array.prototype.forEach.call(el.querySelectorAll("a.cmx-plan"), function (a) { a.href = buyUrl(a.getAttribute("data-offer"), from); });
|
| 123 |
+
if (reason) toast(reason);
|
| 124 |
+
// A real limit: count it, and on phones keep one buy button in reach (no timer; closes with one tap).
|
| 125 |
+
conv(function (K) {
|
| 126 |
+
K.event("limit", C.id); K.event("upgrade-view", C.id);
|
| 127 |
+
var best = (C.plans || []).filter(function (p) { return p.best; })[0] || (C.plans || [])[0];
|
| 128 |
+
if (best && !isPro()) K.sticky({ text: C.name + " Pro: " + best.price + (best.per || ""), cta: "Go Pro", href: buyUrl(best.offer, (from || "limit") + "-bar"), product: C.id });
|
| 129 |
+
});
|
| 130 |
+
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 131 |
+
el.classList.add("cmx-pro-flash"); setTimeout(function () { el.classList.remove("cmx-pro-flash"); }, 1600);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/** Free monthly allowance kept in this browser: quota("pdf", 5) -> {used, left, take()}. */
|
| 135 |
+
function quota(name, limit) {
|
| 136 |
+
var k = "cmx-quota:" + C.id + ":" + name, month = new Date().toISOString().slice(0, 7), q;
|
| 137 |
+
try { q = JSON.parse(localStorage.getItem(k) || "null"); } catch (e) { q = null; }
|
| 138 |
+
if (!q || q.m !== month) q = { m: month, n: 0 };
|
| 139 |
+
return {
|
| 140 |
+
used: q.n, left: Math.max(0, limit - q.n), limit: limit,
|
| 141 |
+
take: function () { if (isPro()) return true; if (q.n >= limit) return false; q.n++; try { localStorage.setItem(k, JSON.stringify(q)); } catch (e) { /* */ } this.used = q.n; this.left = Math.max(0, limit - q.n); return true; },
|
| 142 |
+
};
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function download(name, data, type) {
|
| 146 |
+
var a = document.createElement("a");
|
| 147 |
+
a.href = URL.createObjectURL(data instanceof Blob ? data : new Blob([data], { type: type || "text/csv;charset=utf-8" }));
|
| 148 |
+
a.download = name;
|
| 149 |
+
document.body.appendChild(a); a.click(); a.remove();
|
| 150 |
+
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
window.CMXPro = { isPro: isPro, onChange: function (fn) { listeners.push(fn); fn(isPro()); }, upsell: upsell, quota: quota, download: download, toast: toast };
|
| 154 |
+
|
| 155 |
+
var m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || "");
|
| 156 |
+
function start() {
|
| 157 |
+
if (m && SID.test(decodeURIComponent(m[1]))) {
|
| 158 |
+
var code = decodeURIComponent(m[1]);
|
| 159 |
+
history.replaceState(null, "", location.pathname + location.search);
|
| 160 |
+
apply(); unlock(code);
|
| 161 |
+
} else { apply(); recheck(); }
|
| 162 |
+
}
|
| 163 |
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
|
| 164 |
+
})();
|
hazelsprig/robots.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
User-agent: *
|
| 2 |
+
Allow: /
|
| 3 |
+
Sitemap: https://cybermax-tools-cybermax.static.hf.space/hazelsprig/sitemap.xml
|
hazelsprig/sitemap.xml
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://cybermax-tools-cybermax.static.hf.space/hazelsprig/index.html</loc><lastmod>2026-10-16</lastmod></url></urlset>
|
hazelsprig/style.css
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Hazelsprig. © 2026 CyberMax. All rights reserved. */
|
| 2 |
+
:root{
|
| 3 |
+
--accent:#2E6B45;--accent2:#1F4A30;--ink:#11141B;--muted:#5B6170;--bg:#F6F3EC;--card:#FFFFFF;--line:#E2DED3;--field:#FFFFFF;
|
| 4 |
+
--soft:#E6F1EA;--ok:#17734F;--okbg:#DDF1E7;--over:#A33A0B;--overbg:#FCE6D6;--bar:#2E6B45;--track:#F1ECE4;--onacc:#FFFFFF;
|
| 5 |
+
--shadow:0 1px 2px rgba(17,20,27,.06),0 6px 24px rgba(17,20,27,.06);
|
| 6 |
+
color-scheme:light dark;
|
| 7 |
+
}
|
| 8 |
+
@media (prefers-color-scheme:dark){
|
| 9 |
+
:root{--accent:#7FD3A0;--accent2:#A9E4BF;--ink:#ECEFF3;--muted:#A3A9B6;--bg:#12151B;--card:#1B1F27;--line:#2E3440;--field:#141820;--soft:#18291F;
|
| 10 |
+
--ok:#6DD3A3;--okbg:#173126;--over:#FFB07A;--overbg:#3A2416;--bar:#7FD3A0;--track:#262B35;--onacc:#0B1A10;--shadow:none}
|
| 11 |
+
}
|
| 12 |
+
*{box-sizing:border-box}
|
| 13 |
+
[hidden]{display:none!important}
|
| 14 |
+
html{-webkit-text-size-adjust:100%;scroll-padding-top:70px}
|
| 15 |
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
|
| 16 |
+
a{color:var(--accent)}
|
| 17 |
+
.wrap{max-width:1240px;margin:0 auto;padding:0 16px}
|
| 18 |
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
| 19 |
+
h1,h2,h3{line-height:1.2;letter-spacing:-.01em}
|
| 20 |
+
h1{font-size:clamp(1.7rem,4.4vw,2.6rem);margin:18px 0 8px;font-weight:800}
|
| 21 |
+
h2{font-size:1.15rem;margin:0}
|
| 22 |
+
h3{font-size:.98rem;margin:16px 0 8px}
|
| 23 |
+
:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 55%,transparent);outline-offset:2px}
|
| 24 |
+
|
| 25 |
+
/* header */
|
| 26 |
+
.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
|
| 27 |
+
.top-in{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:56px}
|
| 28 |
+
.brand{display:flex;align-items:center;gap:9px;text-decoration:none;color:var(--ink);font-size:1.12rem;min-width:0}
|
| 29 |
+
.brand small{color:var(--muted);font-weight:500;font-size:.78rem}
|
| 30 |
+
.top-nav{display:flex;align-items:center;gap:8px}
|
| 31 |
+
.top-link{color:var(--ink);text-decoration:none;font-weight:600;font-size:.93rem;padding:6px 4px}
|
| 32 |
+
.pill{background:var(--accent);color:var(--onacc);text-decoration:none;font-weight:800;font-size:.8rem;padding:5px 12px;border-radius:999px;letter-spacing:.02em}
|
| 33 |
+
html.cmx-pro .pill{background:transparent;color:var(--accent);border:2px solid var(--accent)}
|
| 34 |
+
|
| 35 |
+
/* hero */
|
| 36 |
+
.hero{padding:4px 0 12px}
|
| 37 |
+
.lede{font-size:1.06rem;max-width:780px;margin:0 0 14px}
|
| 38 |
+
.hero-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
|
| 39 |
+
.hero-note{color:var(--muted);font-size:.9rem}
|
| 40 |
+
.sel-inline select{width:auto;max-width:100%}
|
| 41 |
+
|
| 42 |
+
/* controls */
|
| 43 |
+
.btn{appearance:none;border:2px solid var(--accent);background:var(--accent);color:var(--onacc);font:inherit;font-weight:700;border-radius:10px;padding:9px 16px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;line-height:1.2;justify-content:center}
|
| 44 |
+
.btn:hover{filter:brightness(1.06)}
|
| 45 |
+
.btn:disabled{opacity:.5;cursor:not-allowed}
|
| 46 |
+
.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line)}
|
| 47 |
+
.btn.ghost:hover{border-color:var(--accent)}
|
| 48 |
+
.btn.sm{padding:6px 11px;font-size:.88rem;border-radius:9px}
|
| 49 |
+
.btn.danger{color:#B42318}
|
| 50 |
+
@media (prefers-color-scheme:dark){.btn.danger{color:#FF8A80}}
|
| 51 |
+
.linkbtn{appearance:none;border:0;background:none;color:var(--accent);font:inherit;font-weight:700;padding:0;cursor:pointer;text-decoration:underline}
|
| 52 |
+
.pro-tag{display:inline-block;font-size:.66rem;font-weight:800;letter-spacing:.04em;background:var(--accent);color:var(--onacc);border-radius:5px;padding:1px 5px;vertical-align:middle}
|
| 53 |
+
html.cmx-pro .pro-tag{display:none}
|
| 54 |
+
label{display:block;font-size:.84rem;font-weight:600;margin:10px 0 4px}
|
| 55 |
+
.opt{font-weight:400;color:var(--muted)}
|
| 56 |
+
input,select,textarea{width:100%;font:inherit;font-size:16px;color:var(--ink);background:var(--field);border:1.5px solid var(--line);border-radius:9px;padding:8px 10px;min-width:0}
|
| 57 |
+
textarea{resize:vertical}
|
| 58 |
+
input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}
|
| 59 |
+
.hint{color:var(--muted);font-size:.86rem;margin:6px 0}
|
| 60 |
+
small.hint{display:block;margin:3px 0 0;font-size:.76rem}
|
| 61 |
+
.btns{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px}
|
| 62 |
+
.combo{display:flex;gap:6px}.combo input{flex:1 1 56px}.combo select{width:auto;flex:0 0 92px}
|
| 63 |
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;box-shadow:var(--shadow)}
|
| 64 |
+
.g2{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
|
| 65 |
+
.g3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 12px}
|
| 66 |
+
.span2{grid-column:span 2}
|
| 67 |
+
.file-btn{margin:0;cursor:pointer}
|
| 68 |
+
.limit{font-weight:700;font-size:.9rem;color:var(--muted)}
|
| 69 |
+
.limit.out{color:var(--over)}
|
| 70 |
+
|
| 71 |
+
/* app */
|
| 72 |
+
.app{padding-bottom:10px;display:flex;flex-direction:column;gap:14px}
|
| 73 |
+
.assume-title{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline}
|
| 74 |
+
.assume-title .hint{margin:0}
|
| 75 |
+
.assume-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0 12px}
|
| 76 |
+
.tabs{display:flex;flex-wrap:wrap;gap:4px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:4px;box-shadow:var(--shadow);align-self:flex-start;max-width:100%}
|
| 77 |
+
.tabs button{appearance:none;font:inherit;font-weight:700;border:0;background:transparent;color:var(--ink);padding:8px 14px;border-radius:9px;cursor:pointer}
|
| 78 |
+
.tabs button[aria-selected=true]{background:var(--accent);color:var(--onacc)}
|
| 79 |
+
.tabs button[aria-selected=true] .pro-tag{background:var(--onacc);color:var(--accent)}
|
| 80 |
+
[role=tabpanel]{display:flex;flex-direction:column;gap:14px;min-width:0}
|
| 81 |
+
.picker-head,.hist-head{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;justify-content:space-between}
|
| 82 |
+
.hist-head .btns{margin:0}
|
| 83 |
+
.rec-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
| 84 |
+
.rec{appearance:none;font:inherit;text-align:left;color:var(--ink);background:var(--bg);border:1.5px solid var(--line);border-radius:11px;padding:8px 12px;cursor:pointer;display:flex;flex-direction:column;min-width:0;max-width:100%}
|
| 85 |
+
.rec b{font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 86 |
+
.rec span{font-size:.8rem;color:var(--muted);font-variant-numeric:tabular-nums}
|
| 87 |
+
.rec.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset;background:var(--soft)}
|
| 88 |
+
.fc{font-style:normal;font-weight:800;border-radius:6px;padding:0 5px}
|
| 89 |
+
.fc.ok{color:var(--ok);background:var(--okbg)}
|
| 90 |
+
.fc.over{color:var(--over);background:var(--overbg)}
|
| 91 |
+
.cols{display:grid;grid-template-columns:minmax(0,1fr);gap:14px;align-items:start}
|
| 92 |
+
@media (min-width:980px){.cols{grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)}.result{position:sticky;top:70px}}
|
| 93 |
+
.lines-h{margin-top:18px}
|
| 94 |
+
.line-head{display:none}
|
| 95 |
+
.line{display:grid;grid-template-columns:minmax(0,1fr) 76px 92px 78px 38px;gap:6px;align-items:center;padding:6px 0;border-bottom:1px dashed var(--line)}
|
| 96 |
+
.line-head{grid-template-columns:minmax(0,1fr) 76px 92px 78px 38px;gap:6px;font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.03em}
|
| 97 |
+
@media (min-width:600px){.line-head{display:grid}}
|
| 98 |
+
.l-cost{text-align:right;font-weight:700;font-variant-numeric:tabular-nums;font-size:.92rem}
|
| 99 |
+
.l-err{grid-column:1/-1;color:var(--over);font-size:.8rem}
|
| 100 |
+
.l-err:empty{display:none}
|
| 101 |
+
.line.bad select{border-color:var(--over)}
|
| 102 |
+
.icon-btn{appearance:none;border:1.5px solid var(--line);background:transparent;color:var(--ink);width:38px;height:38px;border-radius:9px;cursor:pointer;font-size:1.1rem;display:inline-flex;align-items:center;justify-content:center}
|
| 103 |
+
.icon-btn:hover{border-color:var(--accent);color:var(--accent)}
|
| 104 |
+
|
| 105 |
+
/* result */
|
| 106 |
+
.res-head{display:flex;flex-direction:column;gap:2px;margin-bottom:10px}
|
| 107 |
+
.tiles{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
| 108 |
+
.tile{border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:var(--bg);display:flex;flex-direction:column;min-width:0}
|
| 109 |
+
.tile span{font-size:.78rem;color:var(--muted);font-weight:600}
|
| 110 |
+
.tile b{font-size:1.45rem;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.25}
|
| 111 |
+
.tile b.ok{color:var(--ok)}.tile b.over{color:var(--over)}
|
| 112 |
+
.tile small{font-size:.78rem;color:var(--muted)}
|
| 113 |
+
.hero-tile{background:var(--soft);border-color:color-mix(in srgb,var(--accent) 35%,var(--line))}
|
| 114 |
+
.hero-tile b{font-size:1.75rem}
|
| 115 |
+
.warns{margin:10px 0 0;padding:0;list-style:none}
|
| 116 |
+
.warns li{background:var(--overbg);color:var(--over);border-radius:8px;padding:6px 10px;font-size:.86rem;margin-top:6px}
|
| 117 |
+
.chart-h{margin-top:16px}
|
| 118 |
+
.bars{display:flex;flex-direction:column;gap:6px}
|
| 119 |
+
.bar{display:grid;grid-template-columns:minmax(0,38%) minmax(0,1fr) auto;gap:8px;align-items:center;font-size:.86rem;border-radius:6px;padding:1px 2px}
|
| 120 |
+
.bar:hover{background:var(--track)}
|
| 121 |
+
.b-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 122 |
+
.b-name em{font-style:normal;font-size:.7rem;color:var(--muted)}
|
| 123 |
+
.b-track{height:12px;background:var(--track);border-radius:4px;overflow:hidden}
|
| 124 |
+
.b-track i{display:block;height:100%;background:var(--bar);border-radius:0 4px 4px 0}
|
| 125 |
+
.b-val{font-variant-numeric:tabular-nums;font-weight:700;text-align:right;white-space:nowrap;min-width:6.4em}
|
| 126 |
+
.b-val small{color:var(--muted);font-weight:600;display:inline-block;min-width:2.6em}
|
| 127 |
+
.act-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
|
| 128 |
+
.act-row .btn{flex:1 1 auto}
|
| 129 |
+
.xlink{margin-top:12px}
|
| 130 |
+
|
| 131 |
+
/* tables */
|
| 132 |
+
.table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}
|
| 133 |
+
.ht{width:100%;border-collapse:collapse;font-size:.9rem;margin-top:8px}
|
| 134 |
+
.ht caption{text-align:left;font-weight:700;padding:4px 0}
|
| 135 |
+
.ht th,.ht td{padding:8px 6px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}
|
| 136 |
+
.ht th{font-size:.76rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted)}
|
| 137 |
+
.ht .num{text-align:right;font-variant-numeric:tabular-nums}
|
| 138 |
+
.ht td.up{color:var(--over);font-weight:700}.ht td.down{color:var(--ok);font-weight:700}
|
| 139 |
+
.ht td.act{white-space:normal;min-width:220px;color:var(--muted);font-size:.84rem}
|
| 140 |
+
.ht input.sold{width:84px;text-align:right;padding:5px 8px}
|
| 141 |
+
|
| 142 |
+
/* ingredients */
|
| 143 |
+
.ing-tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
|
| 144 |
+
.ing-tools input[type=search]{flex:1 1 220px;width:auto}
|
| 145 |
+
.ing-head{display:none}
|
| 146 |
+
.ing{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:4px 8px;padding:10px 0;border-bottom:1px dashed var(--line);align-items:end}
|
| 147 |
+
.ing label{margin:0 0 2px;font-size:.72rem;color:var(--muted)}
|
| 148 |
+
.ing .g-name{grid-column:1/-1}
|
| 149 |
+
.ing>div:nth-child(2),.ing>div:nth-child(3),.ing>div:nth-child(4){grid-column:span 2}
|
| 150 |
+
.ing>div:nth-child(5),.ing>div:nth-child(6){grid-column:span 3}
|
| 151 |
+
.g-cost{grid-column:1/6;display:flex;flex-direction:column;font-variant-numeric:tabular-nums}
|
| 152 |
+
.g-cost span{font-weight:800}.g-cost small{color:var(--muted);font-size:.78rem}
|
| 153 |
+
.g-del{grid-column:6;display:flex;justify-content:flex-end}
|
| 154 |
+
@media (min-width:900px){
|
| 155 |
+
.ing-head{display:grid;grid-template-columns:minmax(0,2.2fr) 80px 92px 96px 72px 92px minmax(0,1.3fr) 38px;gap:8px;font-size:.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-top:8px}
|
| 156 |
+
.ing{grid-template-columns:minmax(0,2.2fr) 80px 92px 96px 72px 92px minmax(0,1.3fr) 38px;padding:6px 0;align-items:center}
|
| 157 |
+
.ing label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
|
| 158 |
+
.ing .g-name,.ing>div:nth-child(n){grid-column:auto}
|
| 159 |
+
}
|
| 160 |
+
.csv-help{margin-top:12px}.csv-help summary{cursor:pointer;font-weight:700}
|
| 161 |
+
.csv-help pre{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;overflow-x:auto;font-size:.82rem}
|
| 162 |
+
|
| 163 |
+
/* locked + matrix */
|
| 164 |
+
.locked{display:flex;flex-wrap:wrap;align-items:center;gap:12px;justify-content:space-between;color:var(--muted);margin-top:10px;padding:12px;border:1.5px dashed var(--line);border-radius:12px}
|
| 165 |
+
.locked p{margin:0;max-width:640px}
|
| 166 |
+
.rule{margin:8px 0}.rule summary{cursor:pointer;font-weight:700}.rule p{margin:8px 0 0;font-size:.92rem}
|
| 167 |
+
.cls{font-weight:800;font-size:.8rem;border-radius:999px;padding:2px 9px;border:1.5px solid currentColor}
|
| 168 |
+
.cls-star{color:#17734F}.cls-plowhorse{color:#1F5FA8}.cls-puzzle{color:#8A5A00}.cls-dog{color:#9A3B12}
|
| 169 |
+
@media (prefers-color-scheme:dark){.cls-star{color:#6DD3A3}.cls-plowhorse{color:#8DBBF2}.cls-puzzle{color:#E8C06A}.cls-dog{color:#FFA27A}}
|
| 170 |
+
.mx-sum{font-size:.92rem}
|
| 171 |
+
.mx-fig{margin:6px 0 0}
|
| 172 |
+
.mx-fig svg{width:100%;height:auto;max-width:640px;display:block}
|
| 173 |
+
.mx-bg{fill:var(--bg);stroke:var(--line)}
|
| 174 |
+
.mx-rule{stroke:var(--muted);stroke-dasharray:5 4;stroke-width:1.2}
|
| 175 |
+
.mx-q{fill:var(--muted);font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
|
| 176 |
+
.mx-ax,.mx-tick{fill:var(--muted);font-size:12px}
|
| 177 |
+
.mx-dot circle{fill:currentColor;stroke:var(--card);stroke-width:2}
|
| 178 |
+
.mx-dot text{fill:var(--ink);font-size:13px;font-weight:700}
|
| 179 |
+
|
| 180 |
+
/* after the app */
|
| 181 |
+
.pro-cta{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:14px;margin:22px 0;border:2px solid var(--accent);background:var(--soft)}
|
| 182 |
+
.pro-cta p{margin:6px 0 0}
|
| 183 |
+
section.how,section.compare,section.faq{margin:34px 0}
|
| 184 |
+
section.how h2,section.compare h2,section.faq h2{font-size:1.45rem;margin-bottom:12px}
|
| 185 |
+
.steps{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));padding:0;margin:0;list-style:none;counter-reset:s}
|
| 186 |
+
.steps li{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 16px 16px 58px;position:relative;counter-increment:s}
|
| 187 |
+
.steps li::before{content:counter(s);position:absolute;left:16px;top:14px;width:30px;height:30px;border-radius:50%;background:var(--accent);color:var(--onacc);font-weight:800;display:flex;align-items:center;justify-content:center}
|
| 188 |
+
.cmp{width:100%;border-collapse:collapse;background:var(--card);border-radius:12px;overflow:hidden;font-size:.94rem}
|
| 189 |
+
.cmp th,.cmp td{padding:10px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
|
| 190 |
+
.cmp thead th{background:var(--soft);font-size:.82rem;text-transform:uppercase;letter-spacing:.03em}
|
| 191 |
+
.cmp tr.us th,.cmp tr.us td{background:color-mix(in srgb,var(--accent) 7%,var(--card))}
|
| 192 |
+
.faq details{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 16px;margin-bottom:8px}
|
| 193 |
+
.faq summary{cursor:pointer;font-weight:700}
|
| 194 |
+
.faq p{margin:8px 0 0}
|
| 195 |
+
.disclaimer{color:var(--muted);font-size:.88rem;border-left:3px solid var(--line);padding-left:12px}
|
| 196 |
+
footer.more{border-top:1px solid var(--line);margin-top:30px;padding:22px 0 40px}
|
| 197 |
+
.more-title{font-weight:800;margin-bottom:10px}
|
| 198 |
+
.more-links{display:flex;flex-wrap:wrap;gap:8px 16px;margin-bottom:16px}
|
| 199 |
+
.more-links a{text-decoration:none;font-weight:600}
|
| 200 |
+
.more-links a:hover{text-decoration:underline}
|
| 201 |
+
.copy{color:var(--muted);font-size:.88rem}
|
| 202 |
+
.toast{position:fixed;left:50%;bottom:20px;transform:translateX(-50%) translateY(20px);background:var(--ink);color:var(--bg);padding:10px 16px;border-radius:10px;font-weight:600;opacity:0;transition:.2s;z-index:50;max-width:calc(100% - 32px);pointer-events:none;text-align:center}
|
| 203 |
+
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
| 204 |
+
#print-area{display:none}
|
| 205 |
+
|
| 206 |
+
@media (max-width:640px){
|
| 207 |
+
.g2{grid-template-columns:1fr}.span2{grid-column:auto}
|
| 208 |
+
.g3{grid-template-columns:1fr}
|
| 209 |
+
.assume-grid{grid-template-columns:1fr 1fr}
|
| 210 |
+
.tabs{display:grid;grid-template-columns:1fr 1fr;width:100%}
|
| 211 |
+
.rec-list{display:grid;grid-template-columns:1fr 1fr}
|
| 212 |
+
.tabs button{padding:8px 4px;font-size:.92rem}
|
| 213 |
+
.line{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 72px 38px}
|
| 214 |
+
.l-item{grid-column:1/-1}
|
| 215 |
+
.cmp thead{display:none}.cmp,.cmp tbody,.cmp tr,.cmp th,.cmp td{display:block;width:100%}
|
| 216 |
+
.cmp tr{border-bottom:1px solid var(--line);padding:6px 0}.cmp th,.cmp td{border:0;padding:3px 12px}
|
| 217 |
+
.top-link{display:none}
|
| 218 |
+
.brand small{display:none}
|
| 219 |
+
.hero-row .btn{flex:1}
|
| 220 |
+
.sel-inline{flex:1 1 100%}.sel-inline select{width:100%}
|
| 221 |
+
.tile b{font-size:1.25rem}.hero-tile b{font-size:1.5rem}
|
| 222 |
+
.bar{grid-template-columns:minmax(0,34%) minmax(0,1fr) auto}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/* Pro box (shared/pro-unlock/pro-plans.css) */
|
| 226 |
+
#cmx-pro.cmx-pro{border:2px solid var(--accent,#b63a10);border-radius:16px;padding:20px;margin:28px 0;background:var(--card,#fff)}
|
| 227 |
+
#cmx-pro h2{margin:0 0 8px}
|
| 228 |
+
#cmx-pro ul{margin:8px 0 14px;padding-left:20px}
|
| 229 |
+
.cmx-pro-badge{background:var(--accent,#b63a10);color:#fff;border-radius:999px;padding:2px 10px;font-size:.7em;vertical-align:middle}
|
| 230 |
+
.cmx-pro-small{font-size:.88em;opacity:.8}
|
| 231 |
+
.cmx-plans{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:10px 0}
|
| 232 |
+
.cmx-plan{position:relative;display:flex;flex-direction:column;gap:2px;padding:14px;border:1px solid var(--line,#d9d3c7);border-radius:12px;text-decoration:none;color:inherit;background:var(--bg,#faf8f3)}
|
| 233 |
+
.cmx-plan:hover,.cmx-plan:focus{border-color:var(--accent,#b63a10);outline:none}
|
| 234 |
+
.cmx-plan.best{border:2px solid var(--accent,#b63a10)}
|
| 235 |
+
.cmx-plan i{position:absolute;top:-10px;right:10px;background:var(--accent,#b63a10);color:#fff;font-style:normal;font-size:.72em;font-weight:700;border-radius:999px;padding:2px 8px}
|
| 236 |
+
.cmx-price{font-size:1.5em;font-weight:800}
|
| 237 |
+
.cmx-price small{font-size:.55em;font-weight:600;opacity:.75}
|
| 238 |
+
.cmx-note{font-size:.82em;opacity:.75}
|
| 239 |
+
.cmx-pro-flash{animation:cmxflash 1.5s ease}
|
| 240 |
+
@keyframes cmxflash{0%,100%{box-shadow:none}30%{box-shadow:0 0 0 6px color-mix(in srgb,var(--accent,#b63a10) 35%,transparent)}}
|
| 241 |
+
#cmx-pro details{margin-top:8px}
|
| 242 |
+
#cmx-pro input{max-width:280px}
|
| 243 |
+
#cmx-pro form{display:flex;flex-wrap:wrap;gap:8px;align-items:end}
|
| 244 |
+
#cmx-pro form label{flex:1 1 220px;margin:0}
|
| 245 |
+
@media (prefers-color-scheme:dark){.cmx-pro-badge,.cmx-plan i{color:var(--onacc)}}
|
| 246 |
+
#cmx-pro{scroll-margin-top:68px}
|
| 247 |
+
|
| 248 |
+
/* printable recipe cost cards */
|
| 249 |
+
.pcard{color:#11141B;background:#fff;font:12.5px/1.45 Helvetica,Arial,sans-serif;padding:0 0 10mm;page-break-after:always;break-after:page;position:relative;min-height:250mm}
|
| 250 |
+
.pcard:last-child{page-break-after:auto;break-after:auto}
|
| 251 |
+
.pcard header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;border-bottom:2px solid #2E6B45;padding-bottom:8px;margin-bottom:12px}
|
| 252 |
+
.pcard h1{font-size:22px;margin:0 0 2px;color:#11141B}
|
| 253 |
+
.pcard header p{margin:0;color:#5B6170}
|
| 254 |
+
.pcard .pc-date{white-space:nowrap}
|
| 255 |
+
.pcard table{width:100%;border-collapse:collapse}
|
| 256 |
+
.pcard th{font-size:10px;text-transform:uppercase;letter-spacing:.04em;text-align:left;border-bottom:1.2px solid #11141B;padding:5px 6px 5px 0}
|
| 257 |
+
.pcard td{padding:5px 6px 5px 0;border-bottom:.6px solid #D9DCE3;font-variant-numeric:tabular-nums}
|
| 258 |
+
.pcard .num{text-align:right}
|
| 259 |
+
.pcard tfoot td{font-weight:700;border-bottom:0;border-top:1.2px solid #11141B}
|
| 260 |
+
.pc-nums{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin:14px 0}
|
| 261 |
+
.pc-nums div{border:1px solid #D9DCE3;border-radius:6px;padding:7px 8px}
|
| 262 |
+
.pc-nums span{display:block;font-size:9.5px;color:#5B6170;text-transform:uppercase;letter-spacing:.04em}
|
| 263 |
+
.pc-nums b{font-size:16px}
|
| 264 |
+
.pc-method h2{font-size:12px;text-transform:uppercase;letter-spacing:.05em;margin:12px 0 4px}
|
| 265 |
+
.pc-method p{white-space:pre-wrap;margin:0}
|
| 266 |
+
.pc-small{color:#5B6170;font-size:10.5px}
|
| 267 |
+
.pc-credit{position:absolute;left:0;bottom:0;margin:0;font-size:10px;color:#8A8F9C}
|
| 268 |
+
|
| 269 |
+
@media print{
|
| 270 |
+
@page{margin:14mm;size:auto}
|
| 271 |
+
html,body{background:#fff!important;color:#11141B}
|
| 272 |
+
body>*{display:none!important}
|
| 273 |
+
body>#print-area{display:block!important}
|
| 274 |
+
.pcard{min-height:0;padding-bottom:12mm}
|
| 275 |
+
.pc-credit{position:static;margin-top:16px}
|
| 276 |
+
tr{break-inside:avoid}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/* Hazelsprig components */
|
| 280 |
+
.step h3{margin:0}
|
| 281 |
+
.hz{border:1.5px solid var(--line);border-radius:12px;padding:10px 12px;margin-top:10px;background:var(--bg)}
|
| 282 |
+
.hz-top{display:flex;align-items:center;gap:8px}
|
| 283 |
+
.hz-top .icon-btn{margin-left:auto;width:32px;height:32px}
|
| 284 |
+
.hz-type{font-weight:800;font-size:.75rem;border-radius:6px;padding:2px 8px;background:var(--track)}
|
| 285 |
+
.t-B{color:#9A3B12}.t-C{color:#1F5FA8}.t-P{color:#5B6170}.t-A{color:#8A5A00}
|
| 286 |
+
@media (prefers-color-scheme:dark){.t-B{color:#FFA27A}.t-C{color:#8DBBF2}.t-P{color:#A3A9B6}.t-A{color:#E8C06A}}
|
| 287 |
+
.ccp-badge{font-weight:800;font-size:.75rem;border-radius:6px;padding:2px 8px;background:var(--accent);color:var(--onacc)}
|
| 288 |
+
.qs{margin-top:8px;border-top:1px dashed var(--line);padding-top:6px}
|
| 289 |
+
.q{display:grid;grid-template-columns:minmax(0,1fr) 140px;gap:10px;align-items:center;padding:4px 0}
|
| 290 |
+
.q span{font-size:.9rem}
|
| 291 |
+
.verdict{margin:8px 0 0;font-weight:700;font-size:.9rem;color:var(--muted)}
|
| 292 |
+
.verdict.is-ccp{color:var(--accent)}
|
| 293 |
+
.verdict.not-ccp{color:var(--ok)}
|
| 294 |
+
.ccp-f{margin-top:6px;border-top:1px dashed var(--line);padding-top:4px}
|
| 295 |
+
.todo{margin:6px 0 0;padding-left:20px;font-size:.92rem}
|
| 296 |
+
.todo li{margin:3px 0}
|
| 297 |
+
.ok-line{color:var(--ok);font-weight:700}
|
| 298 |
+
.alert{background:var(--overbg);color:var(--over);border-radius:10px;padding:10px 12px;font-weight:600;margin:10px 0}
|
| 299 |
+
.sev{font-weight:800;font-size:.76rem;border-radius:999px;padding:2px 9px;border:1.5px solid currentColor}
|
| 300 |
+
.s-Critical{color:#B42318}.s-Major{color:#A35A00}.s-Minor{color:var(--muted)}
|
| 301 |
+
@media (prefers-color-scheme:dark){.s-Critical{color:#FF8A80}.s-Major{color:#F2B566}}
|
| 302 |
+
.st{font-weight:700;font-size:.82rem}
|
| 303 |
+
.st-overdue{color:#B42318}.st-due-soon{color:#A35A00}.st-closed{color:var(--ok)}
|
| 304 |
+
@media (prefers-color-scheme:dark){.st-overdue{color:#FF8A80}.st-due-soon{color:#F2B566}}
|
| 305 |
+
tr.pick{cursor:pointer}
|
| 306 |
+
tr.pick:hover td{background:var(--track)}
|
| 307 |
+
tr.pick.on td{background:var(--soft)}
|
| 308 |
+
.tiers{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;margin-top:10px}
|
| 309 |
+
.tier{border:1.5px solid var(--line);border-radius:12px;padding:12px;background:var(--bg);display:flex;flex-direction:column;gap:4px;align-items:flex-start}
|
| 310 |
+
.tier h3{margin:0;font-size:.98rem}
|
| 311 |
+
.tier p{margin:0 0 6px}
|
| 312 |
+
.locked-tier{border-style:dashed}
|
| 313 |
+
.chk{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;padding:8px 0;border-bottom:1px dashed var(--line)}
|
| 314 |
+
.chk-note{grid-column:1/-1}
|
| 315 |
+
.seg{display:inline-flex;border:1.5px solid var(--line);border-radius:9px;overflow:hidden}
|
| 316 |
+
.seg button{appearance:none;font:inherit;font-weight:700;font-size:.86rem;border:0;background:transparent;color:var(--ink);padding:6px 12px;cursor:pointer;border-left:1.5px solid var(--line)}
|
| 317 |
+
.seg button:first-child{border-left:0}
|
| 318 |
+
.seg button.v-yes{background:var(--okbg);color:var(--ok)}
|
| 319 |
+
.seg button.v-no{background:var(--overbg);color:var(--over)}
|
| 320 |
+
.seg button.v-na{background:var(--track)}
|
| 321 |
+
.score{font-weight:800;font-variant-numeric:tabular-nums}
|
| 322 |
+
.mbars{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:6px;height:150px;align-items:end;margin:6px 0 4px}
|
| 323 |
+
.mb{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;gap:2px;font-size:.74rem}
|
| 324 |
+
.mb i{display:block;width:100%;background:var(--bar);border-radius:4px 4px 0 0;min-height:2px}
|
| 325 |
+
.mb b{font-variant-numeric:tabular-nums}
|
| 326 |
+
.mb span{color:var(--muted)}
|
| 327 |
+
.cols2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
| 328 |
+
.data-row{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:space-between}
|
| 329 |
+
.data-row .btns{margin:0}
|
| 330 |
+
.tiles{grid-template-columns:repeat(4,minmax(0,1fr))}
|
| 331 |
+
@media (max-width:640px){.q{grid-template-columns:1fr}.cols2{grid-template-columns:1fr}.tiles{grid-template-columns:1fr 1fr}.chk{grid-template-columns:1fr}.tabs{grid-template-columns:1fr 1fr 1fr}.mbars{gap:3px}.mb{font-size:.62rem}}
|
| 332 |
+
|
| 333 |
+
/* print: Hazelsprig documents */
|
| 334 |
+
.pcard h2{font-size:13px;text-transform:uppercase;letter-spacing:.05em;margin:14px 0 6px}
|
| 335 |
+
.pcard table{font-size:10.5px}
|
| 336 |
+
.pc-desc p{margin:3px 0}
|
| 337 |
+
.pc-sign{margin-top:22px;font-size:11px}
|
| 338 |
+
.pc-proc li{margin:6px 0}
|
| 339 |
+
.pc-boxes{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
| 340 |
+
.pc-box{border:1px solid #9AA0AC;border-radius:4px;min-height:34px;padding:3px 6px}
|
| 341 |
+
.pc-box.tall{grid-column:span 2;min-height:80px}
|
| 342 |
+
.pc-box span{font-size:9.5px;color:#5B6170;text-transform:uppercase;letter-spacing:.04em}
|
| 343 |
+
.pc-blank td{height:24px}
|
| 344 |
+
.pc-matrix th span{writing-mode:vertical-rl;transform:rotate(180deg);font-size:9.5px;white-space:nowrap}
|
| 345 |
+
.pc-matrix td{height:22px;border:.6px solid #9AA0AC}
|
| 346 |
+
.pc-matrix td:first-child{width:28%}
|
| 347 |
+
.pc-cover{text-align:left;padding-top:40mm}
|
| 348 |
+
.pc-cover h1{font-size:34px;margin:0 0 10px}
|
| 349 |
+
.pc-cover .big{font-size:20px;font-weight:700}
|
hazelsprig/sw.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Hazelsprig offline cache. © 2026 CyberMax. All rights reserved. */
|
| 2 |
+
var CACHE = "hazelsprig-v1";
|
| 3 |
+
var FILES = ["./", "index.html", "style.css", "pro.js", "lib/core.js", "lib/content.js", "app.js", "manifest.webmanifest", "icon.svg", "icon-192.png", "icon-512.png"];
|
| 4 |
+
self.addEventListener("install", function (e) {
|
| 5 |
+
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(FILES); }).then(function () { return self.skipWaiting(); }));
|
| 6 |
+
});
|
| 7 |
+
self.addEventListener("activate", function (e) {
|
| 8 |
+
e.waitUntil(caches.keys().then(function (keys) {
|
| 9 |
+
return Promise.all(keys.filter(function (k) { return k.indexOf("hazelsprig-") === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); }));
|
| 10 |
+
}).then(function () { return self.clients.claim(); }));
|
| 11 |
+
});
|
| 12 |
+
self.addEventListener("fetch", function (e) {
|
| 13 |
+
var req = e.request, url = new URL(req.url);
|
| 14 |
+
if (req.method !== "GET" || url.origin !== location.origin) return;
|
| 15 |
+
var scope = new URL(self.registration.scope);
|
| 16 |
+
if (url.pathname.indexOf(scope.pathname) !== 0) return;
|
| 17 |
+
if (req.mode === "navigate") {
|
| 18 |
+
// Network first for pages so updates arrive quickly; the cached page works offline.
|
| 19 |
+
e.respondWith(fetch(req).then(function (r) {
|
| 20 |
+
var copy = r.clone(); caches.open(CACHE).then(function (c) { c.put("index.html", copy); });
|
| 21 |
+
return r;
|
| 22 |
+
}).catch(function () { return caches.match("index.html"); }));
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
// Cache first for static files in this app's folder, refreshed in the background.
|
| 26 |
+
e.respondWith(caches.match(req, { ignoreSearch: true }).then(function (hit) {
|
| 27 |
+
var net = fetch(req).then(function (r) {
|
| 28 |
+
if (r && r.ok) { var copy = r.clone(); caches.open(CACHE).then(function (c) { c.put(req, copy); }); }
|
| 29 |
+
return r;
|
| 30 |
+
});
|
| 31 |
+
if (hit) { net.catch(function () { /* offline */ }); return hit; }
|
| 32 |
+
return net;
|
| 33 |
+
}));
|
| 34 |
+
});
|