import { buildComparison } from "/shared/roast-comparison.js?v=__ASSET_VERSION__"; import { parseDuration, formatSigned, } from "/shared/time.js?v=__ASSET_VERSION__"; 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 roasterList = []; let planList = []; let roastPage = 1; 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") === "C" ? "C" : "F"; 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"); const filters = new FormData(document.getElementById("roast-filters")), query = String(filters.get("q") || "").toLowerCase(); const filtered = roasts.filter( (r) => (!query || [r.roast?.title, r.filename, r.planTitle, r.roasterName] .join(" ") .toLowerCase() .includes(query)) && (!filters.get("linked") || Boolean(r.roastPlanId) === (filters.get("linked") === "yes")) && (!filters.get("grade") || r.evaluationGrade === filters.get("grade") || r.evaluationStatus === filters.get("grade")), ); roastPage = Math.min(roastPage, Math.max(1, Math.ceil(filtered.length / 25))); const visible = filtered.slice((roastPage - 1) * 25, roastPage * 25); document.getElementById("roasts-count").textContent = filtered.length ? `${(roastPage - 1) * 25 + 1}–${Math.min(roastPage * 25, filtered.length)} of ${filtered.length} roasts` : "0 roasts"; document.getElementById("roasts-prev").disabled = roastPage <= 1; document.getElementById("roasts-next").disabled = roastPage * 25 >= filtered.length; if (!visible.length) { body.innerHTML = `No roasts match. Adjust the filters or upload a finished roast.`; return; } body.replaceChildren( ...visible.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("a"); strong.href = `/roasts?roast=${roast.id}`; strong.addEventListener("click", (event) => event.preventDefault()); 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 && !document.querySelector("#detail-meta :focus")) 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); 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 } = {}) { if ( !keepScroll && openId && (comparisonDirty || afterFormDirty) && !confirm("Discard unsaved roast observations?") ) return; 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.title || plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan", }), ), ); if ( detail.roastPlanId && !planList.some((p) => p.id === detail.roastPlanId) ) planSelect.append( Object.assign(document.createElement("option"), { value: detail.roastPlanId, textContent: detail.planTitle || "Attached 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"); } }); const search = el("input", { type: "search", className: "field-input sm", placeholder: "Find a plan…", }); search.setAttribute("aria-label", "Find a plan to attach"); let searchTimer, searchVersion = 0; search.addEventListener("input", () => { clearTimeout(searchTimer); const version = ++searchVersion; searchTimer = setTimeout(async () => { try { const data = await api( "/api/plans?view=summary&limit=25&q=" + encodeURIComponent(search.value), ); if (version !== searchVersion || !planSelect.isConnected) return; planSelect.replaceChildren( Object.assign(document.createElement("option"), { value: "", textContent: "— none —", }), ...data.plans.map((p) => Object.assign(document.createElement("option"), { value: p.id, textContent: p.title, }), ), ); if ( detail.roastPlanId && !data.plans.some((p) => p.id === detail.roastPlanId) ) planSelect.append( Object.assign(document.createElement("option"), { value: detail.roastPlanId, textContent: detail.planTitle || "Attached plan", }), ); planSelect.value = detail.roastPlanId || ""; } catch (error) { showToast(error.message, "fail"); } }, 250); }); planSelect.setAttribute("aria-label", "Attached plan"); planWrap.append(search, 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`; // The 4s pending-evaluation poll re-runs openDetail; never let that background refresh // overwrite after-roast fields the user is currently editing. const afterForm = document.getElementById("after-form"); if ( !keepScroll || !(afterFormDirty || afterForm.contains(document.activeElement)) ) renderAfterForm(detail); if (!keepScroll) afterFormDirty = false; if ( !keepScroll || (!comparisonDirty && !document .getElementById("comparison-form") .contains(document.activeElement)) ) renderComparison(detail); renderGraph(detail); renderStats(detail); renderEvaluation(detail); renderTable(); if (!keepScroll) card.scrollIntoView({ behavior: "smooth", block: "start" }); } let comparisonDirty = false; function renderComparison(detail) { comparisonDirty = false; document.getElementById("comparison-plan-note").textContent = detail.plan ? "* Illustrative plan temperature; actual readings may use a different probe reference." : "Attach a plan above to see its targets alongside your observations."; const rows = buildComparison(detail); document.getElementById("comparison-body").replaceChildren( ...rows.map((row) => { const tr = el("tr"); tr.dataset.key = row.key; const time = el("input", { className: "field-input sm", name: row.key + ".time", value: row.timeS == null ? "" : fmtTime(row.timeS), placeholder: "m:ss", }); time.setAttribute("aria-label", row.label + " actual time"); const temp = el("input", { className: "field-input sm", name: row.key + ".temp", value: row.tempC == null ? "" : String(displayTemp(row.tempC)), placeholder: "°" + tempUnit, }); temp.setAttribute("aria-label", row.label + " actual temperature"); const note = el("input", { className: "field-input", name: row.key + ".note", value: row.note, }); note.setAttribute("aria-label", row.label + " notes"); const timeCell = el("td"), tempCell = el("td"), noteCell = el("td"); timeCell.append(time); tempCell.append(temp); noteCell.append(note); tr.append( el("td", {}, row.label), el("td", {}, fmtTime(row.target?.timeS)), el( "td", {}, row.target ? displayTemp(row.target.tempC) + "°" + tempUnit + (row.target.illustrative ? "*" : "") : "—", ), timeCell, tempCell, el("td", {}, row.deltaS == null ? "—" : formatSigned(row.deltaS)), noteCell, ); return tr; }), ); document.getElementById("comparison-status").textContent = ""; } document.getElementById("comparison-form").addEventListener("input", () => { comparisonDirty = true; }); document .getElementById("comparison-form") .addEventListener("submit", async (event) => { event.preventDefault(); const id = openId; if (!id) return; const status = document.getElementById("comparison-status"), observations = {}; for (const tr of document.querySelectorAll("#comparison-body tr")) { const [time, temp, note] = tr.querySelectorAll("input"), timeS = time.value.trim() ? parseDuration(time.value) : null; const value = temp.value.trim() ? Number(temp.value) : null, tempC = value === null ? null : tempUnit === "F" ? (value - 32) / 1.8 : value; if ( (time.value.trim() && (timeS === null || timeS < 0)) || (tempC !== null && !Number.isFinite(tempC)) ) { status.textContent = "Check actual times and temperatures."; return; } observations[tr.dataset.key] = { timeS, tempC, note: note.value }; } try { await api(`/api/roasts/${id}`, { method: "PUT", body: JSON.stringify({ observations }), }); comparisonDirty = false; await openDetail(id, { keepScroll: true }); status.textContent = "Observations saved."; } catch (error) { status.textContent = error.message; } }); // ---- after the roast (moved here from the plan worksheet) ------------------- const AFTER_FIELDS = [ "greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour", "restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof", ]; // True while the after-roast form holds unsaved user edits (cleared on save/open). let afterFormDirty = false; document.getElementById("after-form").addEventListener("input", () => { afterFormDirty = true; }); 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 }), }); afterFormDirty = false; showToast("After-roast saved — included in the updated .alog."); } catch (error) { showToast(error.message, "fail"); } finally { button.disabled = false; } }); // Open (or create) the cupping session for this roast's plan — same flow the planner offers. document .getElementById("open-cupping") .addEventListener("click", async (event) => { const button = event.currentTarget; if (!openId || button.disabled) return; const roast = roasts.find((r) => r.id === openId); const planId = roast?.roastPlanId; if (!planId) { showToast( "Attach a plan to this roast first — cupping sessions hang off plans.", "fail", ); return; } button.disabled = true; try { const existing = await api( `/api/cupping?plan=${encodeURIComponent(planId)}`, ).catch(() => ({ sessions: [] })); if (existing.sessions?.length) { location.assign(`/cupping?session=${existing.sessions[0].id}`); return; } const created = await api("/api/cupping", { method: "POST", body: JSON.stringify({ roastPlanId: planId }), }); location.assign(`/cupping?session=${created.session.id}`); } catch (error) { showToast(error.message, "fail"); } finally { button.disabled = false; } }); function closeDetail() { if ( openId && (comparisonDirty || afterFormDirty) && !confirm("Discard unsaved roast observations?") ) return; comparisonDirty = false; afterFormDirty = false; 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."); comparisonDirty = false; afterFormDirty = false; 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; try { roasterList = (await api("/api/roasters")).roasters; } catch { roasterList = []; } try { planList = (await api("/api/plans?view=summary&limit=100")).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(); document.getElementById("roast-filters").addEventListener("input", () => { roastPage = 1; renderTable(); }); document .getElementById("roast-filters") .addEventListener("submit", (event) => event.preventDefault()); document.getElementById("roasts-prev").addEventListener("click", () => { roastPage--; renderTable(); }); document.getElementById("roasts-next").addEventListener("click", () => { roastPage++; renderTable(); });