import { initPlannerWorkspace } from "./planner-workspace.js?v=__ASSET_VERSION__"; import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__"; import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__"; import { formatDuration, formatSigned, parseRangeMidpoint, } from "/shared/time.js?v=__ASSET_VERSION__"; import { CULTIVARS, findCultivar, findProcess, findRoastLevel, } from "/shared/reference-data.js?v=__ASSET_VERSION__"; import { buildPlanCurve, pointsToPathD, tToX, tempToY, curveWarnings } from "/shared/curve.js?v=__ASSET_VERSION__"; import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__"; import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__"; import { renderReportCurve } from "/shared/report.js?v=__ASSET_VERSION__"; import { initPrint } from "./print.js?v=__ASSET_VERSION__"; import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__"; import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__"; import { applyAvatar, initSideNav } from "./nav.js?v=__ASSET_VERSION__"; import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__"; import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__"; import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__"; import { showToast } from "./toast.js?v=__ASSET_VERSION__"; const FIELD_ID_SET = new Set(FIELD_IDS); const STORAGE_PREFIX = "roastPlannerPlan.v2"; let storageKey = null; let remotePlanId = null; let draftSyncedAt = null; // The custom name of the currently loaded plan (server column, not part of the plan JSONB — // see roast_plans.name). Header display prefers this over worksheet field 0.1 when set. let currentPlanName = ""; let lastDrawerOpener = null; let deferredInstallPrompt = null; let lotPicker = null; const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1"; const machineProfile = null; function renderMachineProfileNote() { document.getElementById("machine-profile-note").textContent = "Plans use your inputs. Roaster selection and roast history do not change timing or temperature suggestions."; } // Temperature fields are always stored in state.plan as canonical °C — this is purely a // display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and // the actuator schedule respectively; every other numeric field (%, g, days) is untouched. const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1"; const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/; let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "C" ? "C" : "F"; const cToF = (c) => (c * 9) / 5 + 32; const fToC = (f) => ((f - 32) * 5) / 9; function isTempField(name) { return TEMP_FIELD_RE.test(name); } // Canonical °C string -> what the field should display right now. function tempToDisplay(canonical) { const n = Number.parseFloat(canonical); if (!Number.isFinite(n)) return canonical ?? ""; return String(Math.round(tempUnit === "F" ? cToF(n) : n)); } // What the user typed (in the current display unit) -> canonical °C string. function tempToCanonical(display) { const n = Number.parseFloat(display); if (!Number.isFinite(n)) return display; return String(Math.round((tempUnit === "F" ? fToC(n) : n) * 10) / 10); } export const state = { plan: 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.replaceChildren(); state.plan.blendComponents.forEach((_, i) => { const tr = document.createElement("tr"); tr.append( document.createRange().createContextualFragment(` `), ); tbody.appendChild(tr); }); } function renderBlendCards() { const wrap = document.getElementById("blend-cards"); wrap.replaceChildren(); state.plan.blendComponents.forEach((_, i) => { const card = document.createElement("div"); card.className = "blend-card"; card.append( document.createRange().createContextualFragment(` `), ); 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 renderActuators() {} // Older plans retain their saved schedule for report export. // ---- 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 = isTempField(name) ? tempToDisplay(value) : (value ?? ""); } } updateBlendVisibility(state.plan.fields["2.1"]); document.getElementById("header-coffee-name").textContent = currentPlanName || 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, machineProfile); 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("subtotalA", d(ledger.subtotalA)); fmtOut("pace-factor", `×${ledger.pace.toFixed(2)}`); 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)); ledger.warnings.push(...curveWarnings(buildPlanCurve(state.plan, ledger))); const warnings = document.getElementById("ledger-warnings"); warnings.replaceChildren( ...ledger.warnings.map((warning) => { const item = document.createElement("p"); item.textContent = warning; return item; }), ); warnings.classList.toggle("hidden", ledger.warnings.length === 0); for (const [key, check] of Object.entries(ledger.checks)) { for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) { cell.classList.remove("pass", "fail", "unknown", "informational"); // Drying/Maillard are mathematically derived from DTR (yellow = 0.56 × A), so they can // never fail independently of it — shown as plain figures, not a pass/fail verdict. if (check.informational) cell.classList.add("informational"); else 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 = currentPlanName || state.plan.fields["0.1"] || ""; document.getElementById("header-coffee-name").textContent = currentPlanName || 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 raw = Number.parseFloat(input.value); const v = Number.isFinite(raw) ? (tempUnit === "F" ? fToC(raw) : raw) : NaN; 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.replaceChildren(); 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.replaceChildren(); 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, ); const live = document.getElementById("curve-svg-live"); live.setAttribute("viewBox", "0 0 900 440"); live.innerHTML = renderReportCurve(planPoints, ledger, ref).replace(/^]*>/, "").replace(/<\/svg>$/, ""); } // The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime // is "0:00", since charge is always t=0) — compare against those defaults, not against "", // so a fresh plan doesn't read as already having roast-day data. const BLANK_PLAN = blankPlan(); // True once any value in `obj` differs from the same path in `blank` — used to lift the // "phase-later" muting off Roast Log / After the Roast the moment the user has actually started // using them, without ever hiding the plan column they exist to compare against. function hasVal(obj, blank) { if (obj == null) return false; return Object.keys(obj).some((key) => { const v = obj[key]; return typeof v === "object" && v !== null ? hasVal(v, blank?.[key]) : v !== "" && v != null && v !== blank?.[key]; }); } export function recompute() { document.dispatchEvent(new Event("plan-updated")); const ledger = renderLedger(); renderCurve(ledger); renderBandMarkers(); document .getElementById("sec-roastlog") ?.classList.toggle( "has-data", hasVal(state.plan.planActual, BLANK_PLAN.planActual), ); lotPicker?.updateConsumeRow(); autosave(); } function setSaveStatus(status) { const chip = document.getElementById("autosave-status"); const text = chip.querySelector(".autosave-text"); chip.classList.remove("saving", "saved", "failed"); chip.classList.add(status); text.textContent = { saving: "Saving locally…", saved: "Synced", failed: "Save failed — retry", local: "Saved locally — waiting to sync", }[status] || "Not saved yet"; } let syncQueue = Promise.resolve(); let lastSaveError = ""; function persistDraft(snapshot) { try { if (!storageKey) return false; localStorage.setItem(storageKey, JSON.stringify({plan: snapshot, remotePlanId, syncedAt: draftSyncedAt})); return true; } catch { return false; } } function syncPlan(snapshot = structuredClone(state.plan), locallySaved = persistDraft(snapshot)) { if (!navigator.onLine || !csrfToken()) { lastSaveError = locallySaved ? "Your draft is saved on this device. Reconnect and sign in to sync it before switching plans." : "The draft could not be saved on this device. Reconnect to save it before leaving."; setSaveStatus(locallySaved ? "local" : "failed"); return Promise.resolve(false); } syncQueue = syncQueue.then(async () => { try { const send = (id) => protectedFetch(id ? `/api/plans/${id}` : "/api/plans", { method: id ? "PUT" : "POST", headers: {"content-type":"application/json"}, body: JSON.stringify({plan: snapshot, ...(id ? {} : {name: currentPlanName})}), }); let response = await send(remotePlanId); let body = await response.json().catch(() => ({})); // A deleted plan may still be the last local draft. Recover it as a new owned // plan, but only for the API's explicit not_found response, never other failures. if (remotePlanId && response.status === 404 && body.code === "not_found") { response = await send(null); body = await response.json().catch(() => ({})); } if (!response.ok) { const reason = response.status === 401 || body.code === "csrf_failed" ? "Your sign-in session needs refreshing. Reload and sign in again, then retry." : response.status === 413 ? "This plan exceeds the server's save-size limit." : `The server rejected the save (HTTP ${response.status}${body.code ? `, ${body.code}` : ""}).`; throw new Error(reason); } if (!body.plan?.id) throw new Error("The server returned an invalid save response."); remotePlanId = body.plan.id; draftSyncedAt = body.plan.updated_at; currentPlanName = body.plan.name ?? currentPlanName; // A confirmed server save succeeds even when browser storage is full/disabled. persistDraft(structuredClone(state.plan)); history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`); lastSaveError = ""; setSaveStatus("saved"); return true; } catch (error) { lastSaveError = `${locallySaved ? "Your draft is saved on this device. " : "Your draft has not been saved; keep this page open. "}${error instanceof TypeError ? "Could not reach the server. Check your connection and retry." : error.message}`; setSaveStatus("failed"); return false; } }); return syncQueue; } async function flushCurrentPlan() { clearTimeout(autosave._t); const snapshot = structuredClone(state.plan); return await syncPlan(snapshot, persistDraft(snapshot)); } function autosave() { // Preserve each edit immediately; debounce only the network write. Reloads and // offline roast-day events must not depend on a later timer firing. persistDraft(structuredClone(state.plan)); setSaveStatus("saving"); clearTimeout(autosave._t); autosave._t = setTimeout(() => { flushCurrentPlan(); }, 400); } function loadFromStorage(userId) { try { storageKey = `${STORAGE_PREFIX}:${userId}`; localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId); const machineProfileKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`; // A shared browser must never retain a previous account's local-only draft or cache. for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if ( (key?.startsWith(`${STORAGE_PREFIX}:`) && key !== storageKey && key !== `${STORAGE_PREFIX}:last-user`) || key === "roastPlannerPlan.v1" || (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`) && key !== machineProfileKey) ) localStorage.removeItem(key); } const raw = localStorage.getItem(storageKey); if (!raw) return null; const draft = JSON.parse(raw); if (draft?.plan) { remotePlanId = draft.remotePlanId || null; draftSyncedAt = draft.syncedAt || null; return draft.plan; } return draft; // legacy v2 draft: retain it once, then upgrade on next save } catch { return null; } } function clearDraft() { if (storageKey) localStorage.removeItem(storageKey); try { localStorage.removeItem(`${STORAGE_PREFIX}:last-user`); for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`)) localStorage.removeItem(key); } } catch { /* unavailable storage */ } storageKey = null; remotePlanId = null; draftSyncedAt = 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(); } // Mirrors cultivarAutofill(): picking a process/roast level should feed the same reference // numbers into the ledger that selecting a cultivar does, not just record the choice as text. function processAutofill(key) { const row = findProcess(key); if (!row) return; setValueForName("3.2", formatSigned(row.devModS)); renderFormFromPlan(); } function roastLevelAutofill(key) { const row = findRoastLevel(key); if (!row) return; setValueForName("4.3", formatDuration(parseRangeMidpoint(row.devBand))); renderFormFromPlan(); } function wireCultivarDatalist() { const list = document.getElementById("cultivar-list"); list.replaceChildren( ...CULTIVARS.map((c) => { const option = document.createElement("option"); option.value = c.name; return option; }), ); } function wireForm() { form.addEventListener("input", (e) => { const el = e.target; if (!el.name) return; if (el.type === "radio" && !el.checked) return; setValueForName( el.name, isTempField(el.name) ? tempToCanonical(el.value) : el.value, ); if (el.name === "1.1") cultivarAutofill(el.value); if (el.name === "2.1") updateBlendVisibility(el.value); if (el.name === "3.1") processAutofill(el.value); if (el.name === "4.1") roastLevelAutofill(el.value); if (el.name.startsWith("blendComponents.")) updateBlendTotal(); recompute(); }); } // Refreshes every unit-facing bit of chrome (labels, placeholders, the toggle itself) after // tempUnit changes. Field *values* are handled separately by renderFormFromPlan(). function updateTempUnitUI() { const label = tempUnit === "F" ? "°F" : "°C"; for (const input of document.querySelectorAll( 'input[name$=".tempC"], input[name$=".actualBt"], input[name$=".expectedBt"]', )) input.placeholder = label; for (const span of document.querySelectorAll("#sec-machine .unit-input .unit")) span.textContent = label; const printHeader = document.getElementById("print-machine-temp-th"); if (printHeader) printHeader.textContent = label; for (const input of document.querySelectorAll('#temp-unit-toggle input[name="tempUnit"]')) input.checked = input.value === tempUnit; // Static reference numbers that live outside the plan (chart axes, band captions, the // print worksheet's "Your logged band" column) — these are fixed constants, not plan // data, so they're driven off data-tempc/data-band-lo/hi rather than valueForName(). const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c); for (const el of document.querySelectorAll("[data-tempc]")) el.textContent = String(conv(Number(el.dataset.tempc))); for (const el of document.querySelectorAll("td[data-band-lo], text[data-band-lo]")) { const lo = conv(Number(el.dataset.bandLo)); const hi = conv(Number(el.dataset.bandHi)); el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}–${hi} ${label}${el.dataset.bandSuffix ?? ""}`; } const axisTitle = document.getElementById("print-curve-axis-title"); if (axisTitle) axisTitle.textContent = `BEAN TEMP ${label}`; } function setTempUnit(unit) { tempUnit = unit === "F" ? "F" : "C"; localStorage.setItem(TEMP_UNIT_KEY, tempUnit); updateTempUnitUI(); renderFormFromPlan(); recompute(); } function wireTempUnitToggle() { const toggle = document.getElementById("temp-unit-toggle"); if (!toggle) return; toggle.addEventListener("change", (e) => { if (e.target.name === "tempUnit") setTempUnit(e.target.value); }); } function wireDrawers() { const overlay = document.getElementById("drawer-overlay"); const drawers = [...document.querySelectorAll(".drawer")]; function closeAll() { for (const panel of drawers) { panel.classList.add("hidden"); panel.setAttribute("aria-hidden", "true"); } overlay.classList.add("hidden"); lastDrawerOpener?.focus(); } function open(panel, opener) { lastDrawerOpener = opener; for (const p of drawers) { p.classList.add("hidden"); p.setAttribute("aria-hidden", "true"); } panel.classList.remove("hidden"); panel.setAttribute("aria-hidden", "false"); overlay.classList.remove("hidden"); panel.querySelector("button, input, [href]")?.focus(); } for (const [buttonId, panelId] of [ ["nav-prefill", "panel-prefill"], ["nav-alog", "panel-alog"], ["nav-settings", "panel-settings"], ["nav-import-export", "panel-import-export"], ]) document .getElementById(buttonId) .addEventListener("click", (event) => { document.getElementById("side-nav")?.classList.remove("mobile-open"); open(document.getElementById(panelId), event.currentTarget); }); overlay.addEventListener("click", closeAll); for (const btn of document.querySelectorAll("[data-close-drawer]")) btn.addEventListener("click", closeAll); document.addEventListener("keydown", (event) => { const activeDrawer = drawers.find( (drawer) => !drawer.classList.contains("hidden"), ); if (!activeDrawer) return; if (event.key === "Escape") { closeAll(); return; } if (event.key !== "Tab") return; const focusable = [ ...activeDrawer.querySelectorAll( "button:not([disabled]), input:not([disabled]), [href]", ), ]; const first = focusable[0], last = focusable.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } }); return { open, closeAll }; } async function leaveForPlans(event) { event.preventDefault(); if (!(await flushCurrentPlan())) { showToast(lastSaveError || "The plan could not be saved. Keep this page open and retry.", "fail"); return; } location.assign("/plans"); } // Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders // inline (display: contents). Any action inside closes it — every tool opens a drawer anyway. function wireToolsMenu() { const toggle = document.getElementById("btn-tools-menu"); const group = document.getElementById("header-tools-group"); if (!toggle || !group) return; const close = () => { group.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); }; toggle.addEventListener("click", (event) => { event.stopPropagation(); const open = group.classList.toggle("open"); toggle.setAttribute("aria-expanded", String(open)); }); group.addEventListener("click", (event) => { if (event.target.closest("button")) close(); }); document.addEventListener("click", (event) => { if (!group.classList.contains("open")) return; if (!group.contains(event.target) && event.target !== toggle) close(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); }); } 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("nav-plans").addEventListener("click", leaveForPlans); document.querySelector('#nav-links a[href="/plans"]')?.addEventListener("click", leaveForPlans); document.getElementById("btn-add-blend").addEventListener("click", () => { state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "", }); renderBlend(); renderFormFromPlan(); recompute(); }); document.getElementById("btn-logout").addEventListener("click", async () => { try { await protectedFetch("/api/auth/logout", { method: "POST" }); } finally { clearDraft(); location.replace("/"); } }); } 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(); syncPlan(); }); window.addEventListener("offline", renderConnection); window.addEventListener("beforeinstallprompt", (event) => { event.preventDefault(); deferredInstallPrompt = event; document.getElementById("btn-install").classList.remove("hidden"); }); document.getElementById("btn-install").addEventListener("click", async () => { if (!deferredInstallPrompt) return; deferredInstallPrompt.prompt(); await deferredInstallPrompt.userChoice; deferredInstallPrompt = null; document.getElementById("btn-install").classList.add("hidden"); }); renderConnection(); if ("serviceWorker" in navigator) { // A brand-new visitor has no controller yet, so the *first* activation firing // "controllerchange" is not an update — only a page that was already controlled by a // previous service worker should reload when a new one takes over. const hadController = !!navigator.serviceWorker.controller; const registerWorker = () => { navigator.serviceWorker .register("/sw.js?v=__ASSET_VERSION__") .then((registration) => { const showUpdate = () => document.getElementById("btn-refresh").classList.remove("hidden"); if (registration.waiting) showUpdate(); registration.addEventListener("updatefound", () => registration.installing?.addEventListener("statechange", () => { if (registration.waiting && navigator.serviceWorker.controller) showUpdate(); }), ); }) .catch((error) => console.warn("Service worker registration failed:", error), ); }; if (document.readyState === "complete") registerWorker(); else window.addEventListener("load", registerWorker, {once:true}); navigator.serviceWorker.addEventListener("controllerchange", () => { if (hadController) location.reload(); }); } document.getElementById("btn-refresh").addEventListener("click", async () => { if (!(await flushCurrentPlan())) return; const registration = await navigator.serviceWorker.getRegistration(); registration?.waiting?.postMessage("SKIP_WAITING"); }); } function wireSectionNav() { const links = [...document.querySelectorAll('.section-nav a[href^="#"]')]; 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) { const active = a.getAttribute("href") === id; a.classList.toggle("active", active); a.toggleAttribute("aria-current", active); } } }, { rootMargin: "-20% 0px -70% 0px" }, ); for (const s of sections) observer.observe(s); } async function init() { // The side nav is generated — render it before anything touches its elements (the admin // link reveal below crashed init for admin accounts when this ran later). initSideNav(); try { const meResponse = await fetch("/api/auth/me"); if (!meResponse.ok) { location.replace("/"); return; } const { user } = await meResponse.json(); document.getElementById("account-email").textContent = user.email; document.getElementById("settings-email").textContent = user.email; applyAvatar(document.getElementById("nav-user-avatar"), user); if (user.role === "admin") document.getElementById("nav-admin")?.classList.remove("hidden"); document.getElementById("alog-library-refresh").hidden = user.role !== "admin"; const localDraft = loadFromStorage(user.id); state.plan = localDraft ?? blankPlan(); const draftRemoteId = remotePlanId; const draftSyncedAtAtLoad = draftSyncedAt; const requestedId = new URLSearchParams(location.search).get("plan"); let selected = null; if (requestedId) { const response = await fetch(`/api/plans/${encodeURIComponent(requestedId)}`); if (!response.ok) { showToast("That plan could not be opened. Returning to your plans.", "fail"); location.replace("/plans"); return; } selected = (await response.json()).plan; } else if (!localDraft) { const recent = await api("/api/plans?view=summary&limit=1"); if (recent.plans[0]) selected = (await api(`/api/plans/${recent.plans[0].id}`)).plan; } const draftIsCurrent = selected && draftRemoteId === selected.id && draftSyncedAtAtLoad && new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad); if (selected) { if (!draftIsCurrent) state.plan = { ...blankPlan(), ...selected.plan, fields: {...blankPlan().fields,...selected.plan.fields} }; remotePlanId = selected.id; draftSyncedAt = selected.updated_at; currentPlanName = selected.name || ""; } } catch { // The cached app shell contains no user data. A successful online sign-in records the // last account only until logout, allowing that account's local draft to reopen offline. try { const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`); if (offlineUserId && csrfToken()) { state.plan = loadFromStorage(offlineUserId) ?? blankPlan(); } } catch { /* no local draft is available */ } } renderBlend(); renderActuators(); wireCultivarDatalist(); wireWhyPanels(); initFieldHelp({ getPlan: () => state.plan, getTempUnit: () => tempUnit, getMachineProfile: () => machineProfile, }); initPlannerWorkspace({state,recompute,renderFormFromPlan,flushCurrentPlan,getRemotePlanId:()=>remotePlanId,rename:async name=>{if(!await flushCurrentPlan())return showToast(lastSaveError,"fail");try{const result=await api(`/api/plans/${remotePlanId}`,{method:"PUT",body:JSON.stringify({name})});currentPlanName=result.plan.name;renderFormFromPlan();}catch(e){showToast(e.message,"fail");}}}); renderBandRanges(); renderFormFromPlan(); wireForm(); wireDrawers(); wireToolbar(); wireToolsMenu(); wireTempUnitToggle(); updateTempUnitUI(); wirePwa(); wireSectionNav(); initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute, }); initAlogPanel({ state, recompute, getRemotePlanId: () => remotePlanId, flushCurrentPlan, }); initPrint({ getPlan: () => state.plan, getName: () => currentPlanName }); lotPicker = initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId: () => remotePlanId, renderFormFromPlan, }); wireCuppingLink(); initPlanChat({ getPlan: () => state.plan, recompute, renderFormFromPlan, flushCurrentPlan, getRemotePlanId:()=>remotePlanId }); renderMachineProfileNote(); recompute(); } function wireCuppingLink() { const button = document.getElementById("btn-open-cupping"); const note = document.getElementById("cupping-link-note"); // Defensive: this UI lives in the Roast Log section; a missing element must degrade to // "no cupping shortcut", never crash init (that failure mode silently killed autosave). if (!button || !note) return; button.addEventListener("click", async () => { button.disabled = true; try { if (!(await flushCurrentPlan())) { note.textContent = "Could not sync the plan. Try again."; return; } if (!remotePlanId) { note.textContent = "Sync the plan first, then open a cupping session."; return; } const existing = await protectedFetch( `/api/cupping?plan=${encodeURIComponent(remotePlanId)}`, ) .then((r) => (r.ok ? r.json() : { sessions: [] })) .catch(() => ({ sessions: [] })); if (existing.sessions?.length) { location.assign(`/cupping?session=${existing.sessions[0].id}`); return; } let created; try { created = await protectedFetch("/api/cupping", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ roastPlanId: remotePlanId }), }).then((r) => r.json()); } catch { note.textContent = "Could not reach the server. Try again."; return; } if (created.session) location.assign(`/cupping?session=${created.session.id}`); else note.textContent = "Could not start a cupping session."; } finally { button.disabled = false; } }); // Non-blocking: show whether a session already exists without forcing a sync first. if (remotePlanId) protectedFetch(`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`) .then((r) => (r.ok ? r.json() : null)) .then((body) => { if (body?.sessions?.length) note.textContent = `Session exists — ${body.sessions[0].totalScore.toFixed(2)}`; }) .catch(() => {}); } init();