Files
roast_command_center/public/js/main.js
T

556 lines
20 KiB
JavaScript

import { FIELD_IDS, blankPlan } from "/shared/fields.js";
import { computeLedger } from "/shared/ledger.js";
import { formatDuration, formatSigned, parseRangeMidpoint } from "/shared/time.js";
import { CULTIVARS, findCultivar } from "/shared/reference-data.js";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
import { initPrefillPanel } from "./prefill-ui.js";
import { initAlogPanel } from "./alog-ui.js";
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);
}
function setPath(obj, path, value) {
const parts = path.split(".");
let node = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (node[parts[i]] === undefined || node[parts[i]] === null) node[parts[i]] = {};
node = node[parts[i]];
}
node[parts[parts.length - 1]] = value;
}
function valueForName(name) {
if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? "";
const v = getPath(state.plan, name);
return v ?? "";
}
function setValueForName(name, value) {
if (FIELD_ID_SET.has(name)) {
state.plan.fields[name] = value;
} else {
setPath(state.plan, name, value);
}
}
// ---- dynamic rows: blend components + actuator schedule
// 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) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
`;
tbody.appendChild(tr);
});
}
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 = `
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
`;
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) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`;
tbody.appendChild(tr);
});
}
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 = `
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card">
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
<label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
<label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
<button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
</div>
`;
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 allNamedInputs()) {
const name = el.name;
if (!name) continue;
const value = valueForName(name);
if (el.type === "radio") {
el.checked = el.value === value;
} else if (el.type === "checkbox") {
el.checked = Boolean(value);
} else {
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) {
for (const el of document.querySelectorAll(`[data-out="${id}"]`)) el.textContent = text ?? "—";
}
function renderLedger() {
const ledger = computeLedger(state.plan);
const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));
fmtOut("l1", d(ledger.lines.l1));
fmtOut("l2", ds(ledger.lines.l2));
fmtOut("l3", ds(ledger.lines.l3));
fmtOut("A", d(ledger.A));
fmtOut("yellow", d(ledger.yellow));
fmtOut("maillard", d(ledger.maillard));
fmtOut("l7", d(ledger.lines.l7));
fmtOut("l8", ds(ledger.lines.l8));
fmtOut("l9", ds(ledger.lines.l9));
fmtOut("C", d(ledger.C));
fmtOut("D", d(ledger.D));
const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`);
fmtOut("check-drying", pct(ledger.checks.drying.pct));
fmtOut("check-maillard", pct(ledger.checks.maillard.pct));
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
for (const [key, check] of Object.entries(ledger.checks)) {
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
fmtOut("t-charge", "0:00");
fmtOut("t-yellow", d(ledger.yellow));
fmtOut("t-fc", d(ledger.A));
fmtOut("t-drop", d(ledger.D));
fmtOut("pa-charge", "0:00");
fmtOut("pa-tp", state.plan.temps.tp.time || "—");
fmtOut("pa-yellow", d(ledger.yellow));
fmtOut("pa-fc", d(ledger.A));
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 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", "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", "currentColor");
c.style.color = "var(--ink, #1a1512)";
planGroup.appendChild(c);
}
}
refGroup.innerHTML = "";
if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = ref.curve
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`)
.join(" ");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
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;
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
c.setAttribute("cx", tToX(m.timeS).toFixed(1));
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6");
c.setAttribute("fill", "none");
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));
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 {
chip.classList.remove("saving", "saved");
text.textContent = "Save unavailable";
}
}, 400);
}
function loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function cultivarAutofill(name) {
const row = findCultivar(name);
if (!row) return;
setValueForName("1.2", row.group);
setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor)));
setValueForName("1.5", row.profile.join("|"));
setValueForName("1.7", formatSigned(row.devModS));
renderFormFromPlan();
}
function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list");
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join("");
}
function wireForm() {
form.addEventListener("input", (e) => {
const el = e.target;
if (!el.name) return;
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 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", () => {
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
});
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
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");
a.href = URL.createObjectURL(blob);
a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-load").click());
document.getElementById("file-load").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const loaded = JSON.parse(await file.text());
state.plan = { ...blankPlan(), ...loaded };
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 wirePwa() {
const status = document.getElementById("connection-status");
const renderConnection = () => {
const offline = !navigator.onLine;
status.classList.toggle("hidden", !offline);
status.textContent = offline ? "Offline — changes continue saving on this device." : "";
};
window.addEventListener("online", renderConnection);
window.addEventListener("offline", renderConnection);
renderConnection();
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => {
console.warn("Service worker registration failed:", error);
});
});
}
}
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() {
renderBlend();
renderActuators();
wireCultivarDatalist();
renderBandRanges();
renderFormFromPlan();
wireForm();
wireDrawers();
wireToolbar();
wirePwa();
wireSectionNav();
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
initAlogPanel({ state, recompute });
initPrint({ beforePrint: renderFormFromPlan });
recompute();
}
init();