-
Attach an Artisan .alog as a reference curve
-
diff --git a/public/js/main.js b/public/js/main.js
index 2a7b0cb..4074e31 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -9,11 +9,22 @@ import { initPrint } from "./print.js";
const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_KEY = "roastPlannerPlan.v1";
+const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
export const state = { plan: loadFromStorage() ?? blankPlan() };
const form = document.getElementById("plan-form");
+// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
+// so its radios don't fight the screen form's identically-named radios for exclusivity.
+// Every sync pass therefore has to reach both containers explicitly.
+function allNamedInputs() {
+ return document.querySelectorAll(
+ "#plan-form input, #plan-form select, #plan-form textarea, " +
+ "#print-sheet input, #print-sheet select, #print-sheet textarea",
+ );
+}
+
// ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar"
function getPath(obj, path) {
return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
@@ -43,7 +54,10 @@ function setValueForName(name, value) {
}
// ---- dynamic rows: blend components + actuator schedule
-function renderBlendRows() {
+// Each renders into TWO places that share `name` attributes: the interactive screen
+// component, and the hidden print-only worksheet table (#blend-rows / #actuator-rows),
+// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
+function renderBlendPrintRows() {
const tbody = document.getElementById("blend-rows");
tbody.innerHTML = "";
state.plan.blendComponents.forEach((_, i) => {
@@ -59,7 +73,56 @@ function renderBlendRows() {
});
}
-function renderActuatorRows() {
+function renderBlendCards() {
+ const wrap = document.getElementById("blend-cards");
+ wrap.innerHTML = "";
+ state.plan.blendComponents.forEach((_, i) => {
+ const card = document.createElement("div");
+ card.className = "blend-card";
+ card.innerHTML = `
+
Cultivar
+
Group
+
Process
+
Share %
+
FC anchor
+
✕
+ `;
+ wrap.appendChild(card);
+ });
+ for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
+ btn.addEventListener("click", () => {
+ const i = Number(btn.dataset.removeBlend);
+ if (state.plan.blendComponents.length <= 1) return;
+ state.plan.blendComponents.splice(i, 1);
+ renderBlend();
+ renderFormFromPlan();
+ recompute();
+ });
+ }
+}
+
+function updateBlendTotal() {
+ const total = state.plan.blendComponents.reduce((sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), 0);
+ const fill = document.getElementById("blend-total-fill");
+ const label = document.getElementById("blend-total-label");
+ if (!fill || !label) return;
+ fill.style.width = `${Math.min(100, total)}%`;
+ fill.classList.toggle("over", total > 100);
+ fill.classList.toggle("under", total > 0 && total < 100);
+ label.textContent = `${Math.round(total * 10) / 10}% of 100%`;
+}
+
+function updateBlendVisibility(mode) {
+ document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend");
+}
+
+function renderBlend() {
+ renderBlendPrintRows();
+ renderBlendCards();
+ updateBlendTotal();
+}
+
+function renderActuatorPrintRows() {
const tbody = document.getElementById("actuator-rows");
tbody.innerHTML = "";
state.plan.actuators.forEach((_, i) => {
@@ -75,9 +138,45 @@ function renderActuatorRows() {
});
}
+function renderActuatorTimeline() {
+ const wrap = document.getElementById("actuator-timeline");
+ wrap.innerHTML = "";
+ state.plan.actuators.forEach((_, i) => {
+ const step = document.createElement("div");
+ step.className = "actuator-step";
+ step.innerHTML = `
+
+
+ Time
+ Heat %
+ Fan %
+ ✕
+ Expected BT
+ Why this change
+
+ `;
+ wrap.appendChild(step);
+ });
+ for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
+ btn.addEventListener("click", () => {
+ const i = Number(btn.dataset.removeActuator);
+ if (state.plan.actuators.length <= 1) return;
+ state.plan.actuators.splice(i, 1);
+ renderActuators();
+ renderFormFromPlan();
+ recompute();
+ });
+ }
+}
+
+function renderActuators() {
+ renderActuatorPrintRows();
+ renderActuatorTimeline();
+}
+
// ---- populate every control in the form from state.plan
export function renderFormFromPlan() {
- for (const el of form.querySelectorAll("input, select, textarea")) {
+ for (const el of allNamedInputs()) {
const name = el.name;
if (!name) continue;
const value = valueForName(name);
@@ -89,11 +188,12 @@ export function renderFormFromPlan() {
el.value = value ?? "";
}
}
+ updateBlendVisibility(state.plan.fields["2.1"]);
+ document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
}
function fmtOut(id, text) {
- const el = document.querySelector(`[data-out="${id}"]`);
- if (el) el.textContent = text ?? "—";
+ for (const el of document.querySelectorAll(`[data-out="${id}"]`)) el.textContent = text ?? "—";
}
function renderLedger() {
@@ -120,10 +220,10 @@ function renderLedger() {
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
for (const [key, check] of Object.entries(ledger.checks)) {
- const cell = document.querySelector(`[data-pass="${key}"]`);
- if (!cell) continue;
- cell.classList.remove("pass", "fail", "unknown");
- cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
+ for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
+ cell.classList.remove("pass", "fail", "unknown");
+ cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
+ }
}
// Box 8 / box 11 derived time columns
@@ -138,33 +238,67 @@ function renderLedger() {
fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || "";
+ document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
return ledger;
}
-function renderCurve(ledger) {
- const planPoints = buildPlanCurve(state.plan, ledger);
- const planGroup = document.getElementById("plan-curve");
+function domainPct(v) {
+ const [lo, hi] = BAND_DOMAIN;
+ return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
+}
+
+function renderBandRanges() {
+ for (const row of document.querySelectorAll(".band-row")) {
+ const lo = Number(row.dataset.bandLo);
+ const hi = Number(row.dataset.bandHi);
+ const range = row.querySelector(".band-range");
+ if (!range) continue;
+ const loPct = domainPct(lo);
+ range.style.left = `${loPct}%`;
+ range.style.width = `${domainPct(hi) - loPct}%`;
+ }
+}
+
+function renderBandMarkers() {
+ for (const row of document.querySelectorAll(".band-row")) {
+ const input = row.querySelector('input[name$=".tempC"]');
+ const marker = row.querySelector(".band-marker");
+ if (!input || !marker) continue;
+ const v = Number.parseFloat(input.value);
+ if (!Number.isFinite(v)) {
+ marker.style.display = "none";
+ continue;
+ }
+ const lo = Number(row.dataset.bandLo);
+ const hi = Number(row.dataset.bandHi);
+ marker.style.display = "block";
+ marker.style.left = `${domainPct(v)}%`;
+ marker.classList.toggle("out-of-band", v < lo || v > hi);
+ }
+}
+
+function paintCurveInto(planGroup, refGroup, planPoints, ref) {
planGroup.innerHTML = "";
if (planPoints.length > 0) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pointsToPathD(planPoints));
path.setAttribute("fill", "none");
- path.setAttribute("stroke", "#1a1512");
+ path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.6");
+ path.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(path);
for (const p of planPoints) {
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
c.setAttribute("cx", tToX(p.timeS).toFixed(1));
c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
c.setAttribute("r", "3.2");
- c.setAttribute("fill", "#1a1512");
+ c.setAttribute("fill", "currentColor");
+ c.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(c);
}
}
- const ref = state.plan.reference;
- const refGroup = document.getElementById("ref-curve");
refGroup.innerHTML = "";
if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
@@ -173,10 +307,11 @@ function renderCurve(ledger) {
.join(" ");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
- path.setAttribute("stroke", "#5a5048");
+ path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.3");
path.setAttribute("stroke-dasharray", "4 3");
path.setAttribute("opacity", ".65");
+ path.style.color = "var(--ink-2, #5a5048)";
refGroup.appendChild(path);
for (const m of ref.milestones ?? []) {
if (m.tempC === null) continue;
@@ -185,27 +320,64 @@ function renderCurve(ledger) {
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6");
c.setAttribute("fill", "none");
- c.setAttribute("stroke", "#5a5048");
+ c.setAttribute("stroke", "currentColor");
c.setAttribute("stroke-width", "1.2");
+ c.style.color = "var(--ink-2, #5a5048)";
refGroup.appendChild(c);
}
}
}
+function renderCurve(ledger) {
+ const planPoints = buildPlanCurve(state.plan, ledger);
+ const ref = state.plan.reference;
+
+ paintCurveInto(
+ document.getElementById("plan-curve"),
+ document.getElementById("ref-curve"),
+ planPoints,
+ ref,
+ );
+ paintCurveInto(
+ document.getElementById("plan-curve-live"),
+ document.getElementById("ref-curve-live"),
+ planPoints,
+ ref,
+ );
+}
+
export function recompute() {
const ledger = renderLedger();
renderCurve(ledger);
+ renderBandMarkers();
autosave();
}
+let autosaveAgeTimer = null;
function autosave() {
+ const chip = document.getElementById("autosave-status");
+ const text = chip.querySelector(".autosave-text");
+ chip.classList.remove("saved");
+ chip.classList.add("saving");
+ text.textContent = "Saving…";
+
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
- document.getElementById("autosave-status").textContent = `saved ${new Date().toLocaleTimeString()}`;
+ const savedAt = Date.now();
+ chip.classList.remove("saving");
+ chip.classList.add("saved");
+ const tick = () => {
+ const secs = Math.round((Date.now() - savedAt) / 1000);
+ text.textContent = secs < 8 ? "Saved just now" : secs < 60 ? `Saved ${secs}s ago` : `Saved ${Math.round(secs / 60)}m ago`;
+ };
+ tick();
+ clearInterval(autosaveAgeTimer);
+ autosaveAgeTimer = setInterval(tick, 5000);
} catch {
- /* storage unavailable — non-fatal */
+ chip.classList.remove("saving", "saved");
+ text.textContent = "Save unavailable";
}
}, 400);
}
@@ -241,17 +413,49 @@ function wireForm() {
if (el.type === "radio" && !el.checked) return;
setValueForName(el.name, el.value);
if (el.name === "1.1") cultivarAutofill(el.value);
+ if (el.name === "2.1") updateBlendVisibility(el.value);
+ if (el.name.startsWith("blendComponents.")) updateBlendTotal();
recompute();
});
}
-function wireToolbar() {
+function wireDrawers() {
+ const overlay = document.getElementById("drawer-overlay");
+ const prefill = document.getElementById("panel-prefill");
+ const alog = document.getElementById("panel-alog");
+
+ function open(panel) {
+ for (const p of [prefill, alog]) p.classList.add("hidden");
+ panel.classList.remove("hidden");
+ panel.setAttribute("aria-hidden", "false");
+ overlay.classList.remove("hidden");
+ }
+ function closeAll() {
+ for (const p of [prefill, alog]) {
+ p.classList.add("hidden");
+ p.setAttribute("aria-hidden", "true");
+ }
+ overlay.classList.add("hidden");
+ }
+
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
- document.getElementById("panel-prefill").classList.toggle("hidden");
+ prefill.classList.contains("hidden") ? open(prefill) : closeAll();
});
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
- document.getElementById("panel-alog").classList.toggle("hidden");
+ alog.classList.contains("hidden") ? open(alog) : closeAll();
});
+ overlay.addEventListener("click", closeAll);
+ for (const btn of document.querySelectorAll("[data-close-drawer]")) {
+ btn.addEventListener("click", closeAll);
+ }
+}
+
+function wireToolbar() {
+ document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
+ const on = document.body.classList.toggle("show-fids");
+ e.currentTarget.setAttribute("aria-pressed", String(on));
+ });
+
document.getElementById("btn-save").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" });
const a = document.createElement("a");
@@ -267,26 +471,62 @@ function wireToolbar() {
try {
const loaded = JSON.parse(await file.text());
state.plan = { ...blankPlan(), ...loaded };
- renderBlendRows();
- renderActuatorRows();
+ renderBlend();
+ renderActuators();
renderFormFromPlan();
recompute();
} catch (err) {
alert(`Could not load plan: ${err.message}`);
}
});
+
+ document.getElementById("btn-add-blend").addEventListener("click", () => {
+ state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" });
+ renderBlend();
+ renderFormFromPlan();
+ recompute();
+ });
+ document.getElementById("btn-add-actuator").addEventListener("click", () => {
+ state.plan.actuators.push({ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" });
+ renderActuators();
+ renderFormFromPlan();
+ recompute();
+ });
+}
+
+function wireSectionNav() {
+ const links = [...document.querySelectorAll(".section-nav a")];
+ const sections = links
+ .map((a) => document.querySelector(a.getAttribute("href")))
+ .filter(Boolean);
+ if (sections.length === 0) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+ const id = `#${entry.target.id}`;
+ for (const a of links) a.classList.toggle("active", a.getAttribute("href") === id);
+ }
+ },
+ { rootMargin: "-20% 0px -70% 0px" },
+ );
+ for (const s of sections) observer.observe(s);
}
function init() {
- renderBlendRows();
- renderActuatorRows();
+ renderBlend();
+ renderActuators();
wireCultivarDatalist();
+ renderBandRanges();
renderFormFromPlan();
wireForm();
+ wireDrawers();
wireToolbar();
- initPrefillPanel({ state, renderBlendRows, renderActuatorRows, renderFormFromPlan, recompute });
+ wireSectionNav();
+ initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
initAlogPanel({ state, recompute });
- initPrint({ form });
+ initPrint({ beforePrint: renderFormFromPlan });
recompute();
}
diff --git a/public/js/print.js b/public/js/print.js
index 3dfe179..b385a21 100644
--- a/public/js/print.js
+++ b/public/js/print.js
@@ -1,22 +1,32 @@
// Bakes live input values into their sibling .pv span before printing (so text doesn't clip
// at a fixed .f width the way a raw
would), then clears them after. One DOM, one
// rendering — see app.css's @media print block for the corresponding display swap.
+//
+// Operates on #print-sheet (not the passed-in screen `form`): the print worksheet lives
+// outside #plan-form on purpose, so its inputs are addressed by container id instead.
-export function initPrint({ form }) {
+export function initPrint({ beforePrint }) {
document.getElementById("btn-print").addEventListener("click", () => window.print());
- window.addEventListener("beforeprint", () => bakeValues(form));
- window.addEventListener("afterprint", () => clearBaked(form));
+ window.addEventListener("beforeprint", () => {
+ beforePrint?.(); // re-sync the print worksheet's inputs from state.plan before baking
+ bakeValues();
+ });
+ window.addEventListener("afterprint", () => clearBaked());
}
-function bakeValues(form) {
- for (const el of form.querySelectorAll("input.ws-input, textarea.ws-input")) {
+function printSheet() {
+ return document.getElementById("print-sheet");
+}
+
+function bakeValues() {
+ for (const el of printSheet().querySelectorAll("input.ws-input, textarea.ws-input")) {
if (el.type === "radio" || el.type === "checkbox") continue;
const pv = el.nextElementSibling;
if (pv && pv.classList.contains("pv")) pv.textContent = el.value || "";
}
}
-function clearBaked(form) {
- for (const pv of form.querySelectorAll(".pv")) pv.textContent = "";
+function clearBaked() {
+ for (const pv of printSheet().querySelectorAll(".pv")) pv.textContent = "";
}