import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; import { showToast } from "./toast.js?v=__ASSET_VERSION__"; let roasters = []; let selectedId = null; let profile = null; const fmtTime = (s) => s == null ? "—" : `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`; // Metric rows of the report: what the planner actually consumes from a machine's profile. // `fmt` renders read-only values; overrides are edited in the raw unit (°C / seconds / ×). const METRICS = [ { key: "chargeTempC", label: "Charge temp (curve fallback)", unit: "°C" }, { key: "turningPointS", label: "Turning point time (thermal lag)", unit: "s", fmt: fmtTime }, { key: "turningPointTempC", label: "Turning point temp", unit: "°C" }, { key: "yellowTempC", label: "Yellow temp (curve fallback)", unit: "°C" }, { key: "firstCrackTempC", label: "First crack temp (curve fallback)", unit: "°C" }, { key: "dropTempC", label: "Drop temp (curve fallback)", unit: "°C" }, { key: "paceFactor", label: "Pace factor (multiplies first-crack timing)", unit: "×" }, ]; function renderRoasters() { const body = document.getElementById("roasters-body"); if (!roasters.length) { body.innerHTML = `No roasters yet — add your machine below. Your first one becomes the default.`; return; } body.replaceChildren( ...roasters.map((roaster) => { const tr = document.createElement("tr"); tr.style.cursor = "pointer"; if (roaster.id === selectedId) tr.style.background = "var(--surface-sunken)"; const nameCell = document.createElement("td"); const strong = document.createElement("strong"); strong.textContent = roaster.name; nameCell.append(strong); if (roaster.model) { const sub = document.createElement("div"); sub.className = "muted"; sub.style.fontSize = "11.5px"; sub.textContent = roaster.model; nameCell.append(sub); } const defaultCell = document.createElement("td"); defaultCell.textContent = roaster.isDefault ? "✓ default" : ""; const countCell = document.createElement("td"); countCell.className = "num"; countCell.textContent = roaster.roastCount ?? 0; const actions = document.createElement("td"); actions.className = "data-table-actions"; if (!roaster.isDefault) { const makeDefault = document.createElement("button"); makeDefault.className = "ghost-btn small"; makeDefault.type = "button"; makeDefault.textContent = "Make default"; makeDefault.addEventListener("click", async (event) => { event.stopPropagation(); try { await api(`/api/roasters/${roaster.id}`, { method: "PUT", body: JSON.stringify({ isDefault: true }), }); showToast(`${roaster.name} is now the default.`); await loadRoasters(); } catch (error) { showToast(error.message, "fail"); } }); actions.append(makeDefault); } // Rename/model edits reuse the existing PUT — without this, a typo'd machine // could only be deleted, which detaches every roast logged against it. const editBtn = document.createElement("button"); editBtn.className = "ghost-btn small"; editBtn.type = "button"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", async (event) => { event.stopPropagation(); const name = prompt("Machine name:", roaster.name); if (name == null) return; if (!name.trim()) { showToast("Name cannot be empty.", "fail"); return; } const model = prompt("Model (optional):", roaster.model ?? ""); if (model == null) return; try { await api(`/api/roasters/${roaster.id}`, { method: "PUT", body: JSON.stringify({ name: name.trim(), model: model.trim() }), }); showToast("Roaster updated."); await loadRoasters(); if (selectedId === roaster.id) renderReport(); } catch (error) { showToast(error.message, "fail"); } }); actions.append(editBtn); const deleteBtn = document.createElement("button"); deleteBtn.className = "ghost-btn small"; deleteBtn.type = "button"; deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", async (event) => { event.stopPropagation(); if ( !confirm( `Delete "${roaster.name}"? Its logged roasts are kept but detach from it.`, ) ) return; try { await api(`/api/roasters/${roaster.id}`, { method: "DELETE" }); showToast("Roaster deleted."); if (selectedId === roaster.id) selectedId = null; await loadRoasters(); } catch (error) { showToast(error.message, "fail"); } }); actions.append(deleteBtn); tr.append(nameCell, defaultCell, countCell, actions); tr.addEventListener("click", () => selectRoaster(roaster.id)); return tr; }), ); } function overrideInputs() { return [...document.querySelectorAll("#report-rows input[data-key]")]; } function renderReport() { const card = document.getElementById("report-card"); if (!selectedId || !profile) { card.classList.add("hidden"); return; } card.classList.remove("hidden"); const roaster = roasters.find((r) => r.id === selectedId); document.getElementById("report-title").textContent = `Analysis — ${roaster?.name ?? ""}`; document.getElementById("report-count").textContent = `${profile.n} roast${profile.n === 1 ? "" : "s"} analyzed`; document.getElementById("report-empty").hidden = profile.n > 0; const rows = document.getElementById("report-rows"); rows.replaceChildren( ...METRICS.map((metric) => { const tr = document.createElement("tr"); const label = document.createElement("td"); label.textContent = metric.label; const learnedCell = document.createElement("td"); const learnedValue = metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key]; learnedCell.textContent = learnedValue == null ? metric.key === "paceFactor" ? "learned from plan logs" : "—" : `${metric.fmt ? metric.fmt(learnedValue) : learnedValue} ${metric.fmt ? "" : metric.unit}`.trim(); const overrideCell = document.createElement("td"); const input = document.createElement("input"); input.className = "field-input sm"; input.type = "number"; input.step = "any"; input.style.maxWidth = "110px"; input.dataset.key = metric.key; input.placeholder = metric.unit; const overrideValue = profile.overrides?.[metric.key]; if (overrideValue != null) input.value = overrideValue; overrideCell.append(input); const appliedCell = document.createElement("td"); const applied = overrideValue ?? (metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key]); appliedCell.textContent = applied == null ? metric.key === "paceFactor" ? "plan-log pace" : "reference default" : `${metric.fmt ? metric.fmt(applied) : applied} ${metric.fmt ? "" : metric.unit}${overrideValue != null ? " (your tweak)" : " (learned)"}`.trim(); tr.append(label, learnedCell, overrideCell, appliedCell); return tr; }), ); const ror = profile.rorCPerMin ?? {}; document.getElementById("report-ror").textContent = profile.n > 0 ? `Typical rate of rise on this machine — drying ${ror.drying ?? "—"} °C/min, Maillard ${ror.maillard ?? "—"} °C/min, development ${ror.development ?? "—"} °C/min.` : ""; const adjustments = document.getElementById("report-adjustments"); adjustments.replaceChildren(); const list = document.createElement("ul"); list.style.paddingLeft = "18px"; list.style.fontSize = "13px"; const items = []; if (profile.n > 0) items.push( "Blank milestone temps on the planner's curve use this machine's learned medians instead of the reference machine.", `New plans assume the turning point around ${fmtTime(profile.medians?.turningPointS)} — this machine's measured thermal lag.`, ); if (profile.paceFactorOverride != null) items.push( `First-crack timing is multiplied by your pace tweak ×${profile.paceFactorOverride} instead of the plan-log learned pace.`, ); items.push( "Roast reviews compare each new roast against this machine's own typical behavior, not a generic one.", ); if (!(roaster?.isDefault ?? false)) items.push( "Note: the planner uses the DEFAULT machine's profile — make this one default to plan with it.", ); for (const text of items) { const li = document.createElement("li"); li.textContent = text; list.append(li); } adjustments.append(list); } async function selectRoaster(id) { selectedId = id; try { profile = (await api(`/api/roaster-profile?roaster=${encodeURIComponent(id)}`)).profile; } catch (error) { showToast(error.message, "fail"); profile = null; } renderRoasters(); renderReport(); } async function saveOverrides(clear = false) { if (!selectedId) return; const overrides = {}; if (!clear) for (const input of overrideInputs()) { if (input.value === "") continue; const value = Number(input.value); if (!Number.isFinite(value)) { showToast(`"${input.value}" is not a number.`, "fail"); return; } overrides[input.dataset.key] = value; } try { await api(`/api/roasters/${selectedId}`, { method: "PUT", body: JSON.stringify({ overrides }), }); showToast(clear ? "Tweaks cleared — using learned values." : "Tweaks saved."); await loadRoasters(); await selectRoaster(selectedId); } catch (error) { showToast(error.message, "fail"); } } async function loadRoasters() { try { roasters = (await api("/api/roasters")).roasters; renderRoasters(); } catch { document.getElementById("roasters-body").innerHTML = `Could not load roasters.`; } } document.getElementById("roaster-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.target; try { const created = await api("/api/roasters", { method: "POST", body: JSON.stringify({ name: form.name.value, model: form.model.value }), }); form.reset(); showToast("Roaster added."); await loadRoasters(); await selectRoaster(created.roaster.id); } catch (error) { showToast(error.message, "fail"); } }); document.getElementById("save-overrides").addEventListener("click", () => saveOverrides(false)); document.getElementById("clear-overrides").addEventListener("click", () => saveOverrides(true)); document.getElementById("btn-logout").addEventListener("click", async () => { await protectedFetch("/api/auth/logout", { method: "POST" }); location.assign("/"); }); async function init() { initSideNav(); const user = await loadNavUser(); if (!user) return; await loadRoasters(); const preferred = roasters.find((r) => r.isDefault) ?? roasters[0]; if (preferred) await selectRoaster(preferred.id); } init();