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__"; import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__"; import { buildPlanCurve } from "/shared/curve.js?v=__ASSET_VERSION__"; let roasts = []; let openId = null; let pollTimer = null; let machineProfile = null; let roasterList = []; let planList = []; const SVG_NS = "http://www.w3.org/2000/svg"; // Same display preference the planner's °C/°F toggle stores — data stays canonical °C, // only labels convert. const tempUnit = localStorage.getItem("roastPlannerTempUnit.v1") === "F" ? "F" : "C"; const displayTemp = (c) => (tempUnit === "F" ? Math.round((c * 9) / 5 + 32) : Math.round(c)); const fmtTime = (s) => s == null ? "—" : `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`; const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—"); const GRADE_LABEL = { excellent: "Excellent", good: "Good", fair: "Fair", "needs-work": "Needs work", }; function renderTable() { const body = document.getElementById("roasts-body"); if (!roasts.length) { body.innerHTML = `No finished roasts uploaded yet. Upload an Artisan .alog above, or from the planner's Reference curve drawer to attach it to a plan.`; return; } body.replaceChildren( ...roasts.map((roast) => { const tr = document.createElement("tr"); tr.style.cursor = "pointer"; if (roast.id === openId) tr.style.background = "var(--paper-2, #f4ede6)"; const roastCell = document.createElement("td"); const strong = document.createElement("strong"); strong.textContent = roast.roast?.title || roast.filename; const sub = document.createElement("div"); sub.className = "muted"; sub.style.fontSize = "11.5px"; sub.textContent = [ roast.roast?.roastDate || fmtDate(roast.createdAt), roast.roasterName, roast.filename, ] .filter(Boolean) .join(" · "); roastCell.append(strong, sub); const planCell = document.createElement("td"); planCell.textContent = roast.planTitle || "—"; const fcCell = document.createElement("td"); fcCell.textContent = fmtTime(roast.derived?.firstCrackS); const dropCell = document.createElement("td"); dropCell.textContent = fmtTime(roast.derived?.dropS); const dtrCell = document.createElement("td"); dtrCell.textContent = roast.derived?.dtrPct == null ? "—" : `${roast.derived.dtrPct}%`; const lossCell = document.createElement("td"); lossCell.textContent = roast.roast?.weightLossPct == null ? "—" : `${roast.roast.weightLossPct}%`; tr.append(roastCell, planCell, fcCell, dropCell, dtrCell, lossCell); tr.addEventListener("click", () => openDetail(roast.id)); return tr; }), ); } async function loadRoasts() { try { roasts = (await api("/api/roasts")).roasts; renderTable(); schedulePoll(); } catch { document.getElementById("roasts-body").innerHTML = `Could not load roasts.`; } } // While any review is still pending, refresh every few seconds so the table/detail fill in as // the LLM finishes; stops by itself once nothing is pending. function schedulePoll() { clearTimeout(pollTimer); if (!roasts.some((roast) => roast.evaluationStatus === "pending")) return; pollTimer = setTimeout(async () => { await loadRoasts(); if (openId) await openDetail(openId, { keepScroll: true }); }, 4000); } // ---- detail view ------------------------------------------------------------ function el(tag, props = {}, text) { const node = document.createElement(tag); Object.assign(node, props); if (text !== undefined) node.textContent = text; return node; } function svgEl(tag, attrs) { const node = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); return node; } function renderGraph(detail) { const svg = document.getElementById("roast-graph"); svg.replaceChildren(); const curve = detail.parsed?.curve ?? []; if (!curve.length) { svg.append( svgEl("text", { x: 357, y: 180, "text-anchor": "middle", fill: "#5a5048", "font-size": 14 }), ); svg.lastChild.textContent = "No telemetry in this log."; return; } // Plan overlay points (same shared math as the planner page), if a plan is attached. let planPoints = []; if (detail.plan) { try { const ledger = computeLedger(detail.plan, machineProfile); planPoints = buildPlanCurve(detail.plan, ledger).filter( (p) => p.timeS != null && p.tempC != null, ); } catch { planPoints = []; } } const x0 = 52, x1 = 692, y0 = 24, y1 = 300; const lastT = Math.max( curve[curve.length - 1].t, ...planPoints.map((p) => p.timeS), 540, ); const tMax = Math.ceil(lastT / 60) * 60; const temps = curve.map((p) => p.bt).concat(planPoints.map((p) => p.tempC)); const tempLo = Math.min(60, Math.floor((Math.min(...temps) - 10) / 20) * 20); const tempHi = Math.max(220, Math.ceil((Math.max(...temps) + 10) / 20) * 20); const xFor = (t) => x0 + (Math.max(0, Math.min(tMax, t)) / tMax) * (x1 - x0); const yFor = (c) => y1 - ((c - tempLo) / (tempHi - tempLo)) * (y1 - y0); // Grid + axis labels const minuteStep = tMax > 900 ? 120 : 60; for (let t = 0; t <= tMax; t += minuteStep) { svg.append( svgEl("line", { x1: xFor(t), y1: y0, x2: xFor(t), y2: y1, stroke: "#e5dcd2", "stroke-width": t === 0 ? 1.2 : 0.7 }), ); const label = svgEl("text", { x: xFor(t), y: y1 + 16, "text-anchor": "middle", fill: "#8a7f74", "font-size": 10 }); label.textContent = `${t / 60}`; svg.append(label); } for (let c = tempLo; c <= tempHi; c += 20) { svg.append( svgEl("line", { x1: x0, y1: yFor(c), x2: x1, y2: yFor(c), stroke: "#e5dcd2", "stroke-width": c === tempLo ? 1.2 : 0.7 }), ); const label = svgEl("text", { x: x0 - 6, y: yFor(c) + 3, "text-anchor": "end", fill: "#8a7f74", "font-size": 10 }); label.textContent = String(displayTemp(c)); svg.append(label); } const xTitle = svgEl("text", { x: (x0 + x1) / 2, y: y1 + 32, "text-anchor": "middle", fill: "#8a7f74", "font-size": 10.5 }); xTitle.textContent = "MINUTES FROM CHARGE"; svg.append(xTitle); const yTitle = svgEl("text", { x: 14, y: (y0 + y1) / 2, fill: "#8a7f74", "font-size": 10.5, transform: `rotate(-90 14 ${(y0 + y1) / 2})`, "text-anchor": "middle" }); yTitle.textContent = `BEAN TEMP °${tempUnit}`; svg.append(yTitle); // Plan curve (dashed) under the actual curve if (planPoints.length) { const d = [...planPoints] .sort((a, b) => a.timeS - b.timeS) .map((p, i) => `${i === 0 ? "M" : "L"}${xFor(p.timeS).toFixed(1)},${yFor(p.tempC).toFixed(1)}`) .join(" "); svg.append( svgEl("path", { d, fill: "none", stroke: "#5a5048", "stroke-width": 1.3, "stroke-dasharray": "4 3", opacity: 0.65 }), ); } // Actual BT curve const d = curve .map((p, i) => `${i === 0 ? "M" : "L"}${xFor(p.t).toFixed(1)},${yFor(p.bt).toFixed(1)}`) .join(" "); svg.append(svgEl("path", { d, fill: "none", stroke: "#A8481A", "stroke-width": 1.8 })); // Milestones + turning point const marks = [...(detail.parsed.milestones ?? [])]; if (detail.parsed.turningPoint) marks.push({ label: "TP", timeS: detail.parsed.turningPoint.timeS, tempC: detail.parsed.turningPoint.tempC }); for (const m of marks) { if (m.tempC == null) continue; svg.append( svgEl("circle", { cx: xFor(m.timeS), cy: yFor(m.tempC), r: 3.4, fill: "#1a1512" }), ); const label = svgEl("text", { x: xFor(m.timeS) + 6, y: yFor(m.tempC) - 6, fill: "#1a1512", "font-size": 10.5 }); label.textContent = `${m.label} ${fmtTime(m.timeS)}`; svg.append(label); } // Legend svg.append(svgEl("line", { x1: x0 + 8, y1: 348, x2: x0 + 36, y2: 348, stroke: "#A8481A", "stroke-width": 1.8 })); const actualLabel = svgEl("text", { x: x0 + 42, y: 351, fill: "#5a5048", "font-size": 10.5 }); actualLabel.textContent = "Actual BT"; svg.append(actualLabel); if (planPoints.length) { svg.append( svgEl("line", { x1: x0 + 118, y1: 348, x2: x0 + 146, y2: 348, stroke: "#5a5048", "stroke-width": 1.3, "stroke-dasharray": "4 3" }), ); const planLabel = svgEl("text", { x: x0 + 152, y: 351, fill: "#5a5048", "font-size": 10.5 }); planLabel.textContent = `Plan${detail.planTitle ? `: ${detail.planTitle}` : ""}`; svg.append(planLabel); } } function statBlock(label, value) { const wrap = el("div", { className: "field" }); wrap.append( el("span", { className: "field-label" }, label), el("div", {}, value), ); return wrap; } function renderStats(detail) { const grid = document.getElementById("detail-stats"); const derived = detail.parsed?.derived; const roast = detail.parsed?.roast; grid.replaceChildren( statBlock("First crack", fmtTime(derived?.firstCrackS)), statBlock("Development", fmtTime(derived?.developmentS)), statBlock("Drop", fmtTime(derived?.dropS)), statBlock("DTR", derived?.dtrPct == null ? "—" : `${derived.dtrPct}%`), statBlock("Drying share", derived?.dryingSharePct == null ? "—" : `${derived.dryingSharePct}%`), statBlock("Maillard share", derived?.maillardSharePct == null ? "—" : `${derived.maillardSharePct}%`), statBlock("Charge → out", `${roast?.weightInG ?? "—"} g → ${roast?.weightOutG ?? "—"} g`), statBlock("Weight loss", roast?.weightLossPct == null ? "—" : `${roast.weightLossPct}%`), ); } function evaluationList(title, items) { const wrap = el("div"); wrap.append(el("strong", {}, title)); const ul = el("ul"); ul.style.margin = "4px 0 12px"; ul.style.paddingLeft = "18px"; for (const item of items) { const li = el("li", {}, item); li.style.fontSize = "13px"; ul.append(li); } wrap.append(ul); return wrap; } function renderEvaluation(detail) { const box = document.getElementById("detail-evaluation"); box.replaceChildren(); const heading = el("h3", {}, "LLM review"); heading.style.margin = "0 0 6px"; box.append(heading); if (detail.evaluationStatus === "pending") { box.append(el("p", { className: "field-note" }, "The LLM is reviewing this roast — this page refreshes automatically.")); return; } if (detail.evaluationStatus === "failed") { const p = el( "p", { className: "field-note" }, `Review failed${detail.evaluationError ? ` (${detail.evaluationError})` : ""}. Use Re-evaluate to try again.`, ); p.style.color = "#a8371a"; box.append(p); return; } const ev = detail.evaluation; if (!ev) { box.append(el("p", { className: "field-note" }, "No review recorded for this roast.")); return; } const grade = el("p", {}, `Grade: ${GRADE_LABEL[ev.grade] ?? ev.grade}`); grade.style.fontWeight = "600"; box.append(grade, el("p", {}, ev.summary)); if (ev.planComparison) box.append(evaluationList("Against the plan", [ev.planComparison])); if (ev.highlights?.length) box.append(evaluationList("What went well", ev.highlights)); if (ev.concerns?.length) box.append(evaluationList("Concerns", ev.concerns)); if (ev.suggestions?.length) box.append(evaluationList("Next batch", ev.suggestions)); if (detail.parsed?.warnings?.length) box.append(evaluationList("Parser notes", detail.parsed.warnings)); } async function openDetail(id, { keepScroll = false } = {}) { let detail; try { detail = (await api(`/api/roasts/${id}`)).roast; } catch (error) { showToast(error.message, "fail"); return; } openId = id; history.replaceState(null, "", `/roasts?roast=${encodeURIComponent(id)}`); const card = document.getElementById("roast-detail-card"); card.classList.remove("hidden"); document.getElementById("detail-title").textContent = detail.roast?.title || detail.filename; const meta = document.getElementById("detail-meta"); meta.textContent = [ detail.roast?.roastDate || fmtDate(detail.createdAt), detail.roast?.roasterType, detail.filename, ] .filter(Boolean) .join(" · "); // Plan attachment: link this roast to one of your plans (enables plan-vs-actual in the // review, the refine handoff, and the plan overlay on the graph). if (planList.length) { const planWrap = document.createElement("span"); planWrap.append(" · Plan: "); const planSelect = document.createElement("select"); planSelect.className = "field-input sm"; planSelect.style.display = "inline-block"; planSelect.style.width = "auto"; planSelect.append( Object.assign(document.createElement("option"), { value: "", textContent: "— none —", }), ...planList.map((plan) => Object.assign(document.createElement("option"), { value: plan.id, textContent: plan.plan?.fields?.["0.1"] || "Untitled plan", }), ), ); planSelect.value = detail.roastPlanId ?? ""; planSelect.addEventListener("change", async () => { try { await api(`/api/roasts/${detail.id}`, { method: "PUT", body: JSON.stringify({ roastPlanId: planSelect.value || null }), }); showToast("Plan attached — re-evaluate to review against its targets."); await loadRoasts(); await openDetail(detail.id, { keepScroll: true }); } catch (error) { showToast(error.message, "fail"); } }); planWrap.append(planSelect); meta.append(planWrap); } else if (detail.planTitle) { meta.append(` · Plan: ${detail.planTitle}`); } // Machine assignment: which of the user's roasters this roast trains. if (roasterList.length) { const wrap = document.createElement("span"); wrap.append(" · Machine: "); const select = document.createElement("select"); select.className = "field-input sm"; select.style.display = "inline-block"; select.style.width = "auto"; select.append( Object.assign(document.createElement("option"), { value: "", textContent: "— none —", }), ...roasterList.map((r) => Object.assign(document.createElement("option"), { value: r.id, textContent: r.name, }), ), ); select.value = detail.roasterId ?? ""; select.addEventListener("change", async () => { try { await api(`/api/roasts/${detail.id}`, { method: "PUT", body: JSON.stringify({ roasterId: select.value || null }), }); showToast("Machine updated — future learning uses this assignment."); await loadRoasts(); } catch (error) { showToast(error.message, "fail"); } }); wrap.append(select); meta.append(wrap); } document.getElementById("detail-download").href = `/api/roasts/${encodeURIComponent(id)}/download`; document.getElementById("detail-download-updated").href = `/api/roasts/${encodeURIComponent(id)}/download?variant=updated`; renderAfterForm(detail); renderGraph(detail); renderStats(detail); renderEvaluation(detail); renderTable(); if (!keepScroll) card.scrollIntoView({ behavior: "smooth", block: "start" }); } // ---- after the roast (moved here from the plan worksheet) ------------------- const AFTER_FIELDS = [ "greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour", "restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof", ]; function renderAfterForm(detail) { const form = document.getElementById("after-form"); const after = detail.after ?? {}; // Prefill measurable values straight from the .alog so the user only types what the // machine couldn't know (colour, cup notes, the one change). const auto = { greenIn: detail.parsed?.roast?.weightInG || "", out: detail.parsed?.roast?.weightOutG || "", weightLossPct: detail.parsed?.roast?.weightLossPct ?? "", actualDtrPct: detail.parsed?.derived?.dtrPct ?? "", }; for (const field of AFTER_FIELDS) form[field].value = after[field] ?? (auto[field] === 0 ? "" : (auto[field] ?? "")); } document.getElementById("after-form").addEventListener("submit", async (event) => { event.preventDefault(); if (!openId) return; const form = event.target; const after = {}; for (const field of AFTER_FIELDS) after[field] = form[field].value; const button = document.getElementById("after-save"); button.disabled = true; try { await api(`/api/roasts/${openId}`, { method: "PUT", body: JSON.stringify({ after }), }); showToast("After-roast saved — included in the updated .alog."); } catch (error) { showToast(error.message, "fail"); } finally { button.disabled = false; } }); function closeDetail() { openId = null; history.replaceState(null, "", "/roasts"); document.getElementById("roast-detail-card").classList.add("hidden"); renderTable(); } // ---- uploads ---------------------------------------------------------------- async function uploadFiles(files) { const status = document.getElementById("upload-status"); status.hidden = false; let uploaded = 0; for (const file of files) { status.textContent = `Uploading ${file.name}…`; try { await api("/api/roasts", { method: "POST", body: JSON.stringify({ filename: file.name, content: await file.text() }), }); uploaded++; } catch (error) { showToast(`${file.name}: ${error.message}`, "fail"); } } status.textContent = uploaded ? `Uploaded ${uploaded} roast${uploaded === 1 ? "" : "s"} — the LLM review runs in the background.` : ""; status.hidden = !status.textContent; await loadRoasts(); } document.getElementById("roast-upload").addEventListener("change", async (event) => { const files = [...(event.target.files ?? [])]; event.target.value = ""; if (files.length) await uploadFiles(files); }); document.getElementById("detail-close").addEventListener("click", closeDetail); document.getElementById("detail-reevaluate").addEventListener("click", async (event) => { if (!openId || event.currentTarget.disabled) return; const button = event.currentTarget; button.disabled = true; try { await api(`/api/roasts/${openId}/evaluate`, { method: "POST" }); showToast("Re-evaluation queued."); await loadRoasts(); await openDetail(openId, { keepScroll: true }); } catch (error) { showToast(error.message, "fail"); } finally { button.disabled = false; } }); document.getElementById("detail-delete").addEventListener("click", async () => { if (!openId) return; const roast = roasts.find((r) => r.id === openId); const name = roast?.roast?.title || roast?.filename || "this roast"; if (!confirm(`Delete "${name}" and its stored .alog? This cannot be undone.`)) return; try { await api(`/api/roasts/${openId}`, { method: "DELETE" }); showToast("Roast deleted."); closeDetail(); await loadRoasts(); } catch (error) { showToast(error.message, "fail"); } }); 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; // Same learned pace the planner uses, so the dashed plan overlay matches the planner's curve. try { machineProfile = (await api("/api/machine-profile")).profile; } catch { machineProfile = null; } try { roasterList = (await api("/api/roasters")).roasters; } catch { roasterList = []; } try { planList = (await api("/api/plans")).plans; } catch { planList = []; } await loadRoasts(); const requested = new URLSearchParams(location.search).get("roast"); if (requested && roasts.some((roast) => roast.id === requested)) await openDetail(requested, { keepScroll: true }); } init();