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 = ` `; 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 = ` `; 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 = ` `; 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 = `
`; 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) => `