Add finished-roast .alog uploads with Pi agent deep review and /roasts history
Test and deploy / test-and-deploy (push) Successful in 1m39s
Test and deploy / test-and-deploy (push) Successful in 1m39s
- POST /api/roasts stores the original .alog verbatim (multiple per plan), parses it server-side, and kicks off an async zero-tool Pi agent review (grade, highlights, concerns, next-batch suggestions, plan-vs-actual) - /roasts page: table of historical actual roasts with review summaries; row detail renders the BT curve as SVG with the plan curve overlaid, shows the full review, and offers original-.alog backup download, re-evaluate, and delete - Planner's Reference curve drawer gains a multi-file finished-roast uploader that attaches to the open (synced) plan - Failed reviews record the error on the row and are retryable via POST /api/roasts/:id/evaluate (e.g. once a model is configured) Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
84ce75dc14
commit
eb82263ead
+58
-2
@@ -9,11 +9,18 @@ const setText = (el, text, error = false) => {
|
||||
if (error) node.style.color = "#a8371a";
|
||||
el.replaceChildren(node);
|
||||
};
|
||||
export function initAlogPanel({ state, recompute }) {
|
||||
export function initAlogPanel({
|
||||
state,
|
||||
recompute,
|
||||
getRemotePlanId,
|
||||
flushCurrentPlan,
|
||||
}) {
|
||||
const fileInput = document.getElementById("alog-file"),
|
||||
libraryBtn = document.getElementById("alog-library-refresh"),
|
||||
libraryList = document.getElementById("alog-library-list"),
|
||||
resultEl = document.getElementById("alog-result");
|
||||
resultEl = document.getElementById("alog-result"),
|
||||
actualInput = document.getElementById("actual-alog-file"),
|
||||
actualResultEl = document.getElementById("actual-alog-result");
|
||||
fileInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -72,6 +79,55 @@ export function initAlogPanel({ state, recompute }) {
|
||||
libraryList.replaceChildren(li);
|
||||
}
|
||||
});
|
||||
// Finished-roast uploads: each file becomes an actual_roasts row attached to the open plan
|
||||
// (which must exist server-side first — hence the flush), and the Pi agent reviews it
|
||||
// asynchronously; the roast history page is where results land.
|
||||
actualInput?.addEventListener("change", async (e) => {
|
||||
const files = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
if (!files.length) return;
|
||||
setText(actualResultEl, "Syncing plan…");
|
||||
await flushCurrentPlan();
|
||||
const planId = getRemotePlanId();
|
||||
if (!planId) {
|
||||
setText(
|
||||
actualResultEl,
|
||||
"Could not sync this plan to the server — connect and try again, or upload from the Roasts page without a plan.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let uploaded = 0;
|
||||
for (const file of files) {
|
||||
setText(actualResultEl, `Uploading ${file.name}…`);
|
||||
try {
|
||||
const res = await fetch("/api/roasts", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
|
||||
body: JSON.stringify({
|
||||
roastPlanId: planId,
|
||||
filename: file.name,
|
||||
content: await file.text(),
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!body.ok) throw new Error(body.error ?? body.code);
|
||||
uploaded++;
|
||||
} catch (err) {
|
||||
setText(actualResultEl, `${file.name}: ${err.message}`, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const node = document.createElement("p");
|
||||
node.append(
|
||||
`Uploaded ${uploaded} roast${uploaded === 1 ? "" : "s"} to this plan. `,
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = "/roasts";
|
||||
link.textContent = "See the review →";
|
||||
node.append(link);
|
||||
actualResultEl.replaceChildren(node);
|
||||
});
|
||||
function applyResult(body) {
|
||||
if (!body.ok) {
|
||||
setText(resultEl, body.error ?? body.code, true);
|
||||
|
||||
+6
-1
@@ -1150,7 +1150,12 @@ async function init() {
|
||||
renderFormFromPlan,
|
||||
recompute,
|
||||
});
|
||||
initAlogPanel({ state, recompute });
|
||||
initAlogPanel({
|
||||
state,
|
||||
recompute,
|
||||
getRemotePlanId: () => remotePlanId,
|
||||
flushCurrentPlan,
|
||||
});
|
||||
initPrint({ beforePrint: renderFormFromPlan });
|
||||
lotPicker = initLotPicker({
|
||||
state,
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
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;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
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 reviewLabel(roast) {
|
||||
if (roast.evaluationStatus === "pending") return "Reviewing…";
|
||||
if (roast.evaluationStatus === "failed") return "Review failed";
|
||||
if (roast.evaluationGrade) return GRADE_LABEL[roast.evaluationGrade] ?? roast.evaluationGrade;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const body = document.getElementById("roasts-body");
|
||||
if (!roasts.length) {
|
||||
body.innerHTML = `<tr><td colspan="7" class="empty-state">No finished roasts uploaded yet. Upload an Artisan .alog above, or from the planner's Reference curve drawer to attach it to a plan.</td></tr>`;
|
||||
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.filename}`;
|
||||
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}%`;
|
||||
|
||||
const reviewCell = document.createElement("td");
|
||||
const chip = document.createElement("span");
|
||||
chip.textContent = reviewLabel(roast);
|
||||
if (roast.evaluationStatus === "failed") chip.style.color = "#a8371a";
|
||||
if (roast.evaluationStatus === "pending") chip.style.fontStyle = "italic";
|
||||
reviewCell.append(chip);
|
||||
if (roast.evaluationSummary) {
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "muted";
|
||||
summary.style.fontSize = "11.5px";
|
||||
summary.style.maxWidth = "320px";
|
||||
summary.textContent = roast.evaluationSummary;
|
||||
reviewCell.append(summary);
|
||||
}
|
||||
|
||||
tr.append(roastCell, planCell, fcCell, dropCell, dtrCell, lossCell, reviewCell);
|
||||
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 =
|
||||
`<tr><td colspan="7" class="empty-state">Could not load roasts.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// While any review is still pending, refresh every few seconds so the table/detail fill in as
|
||||
// the Pi agent 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(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 °C";
|
||||
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", {}, "Pi agent review");
|
||||
heading.style.margin = "0 0 6px";
|
||||
box.append(heading);
|
||||
if (detail.evaluationStatus === "pending") {
|
||||
box.append(el("p", { className: "field-note" }, "The Pi agent 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;
|
||||
document.getElementById("detail-meta").textContent = [
|
||||
detail.roast?.roastDate || fmtDate(detail.createdAt),
|
||||
detail.roast?.roasterType,
|
||||
detail.planTitle ? `Plan: ${detail.planTitle}` : "No plan attached",
|
||||
detail.filename,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
document.getElementById("detail-download").href =
|
||||
`/api/roasts/${encodeURIComponent(id)}/download`;
|
||||
renderGraph(detail);
|
||||
renderStats(detail);
|
||||
renderEvaluation(detail);
|
||||
renderTable();
|
||||
if (!keepScroll) card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
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 Pi agent 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;
|
||||
}
|
||||
await loadRoasts();
|
||||
const requested = new URLSearchParams(location.search).get("roast");
|
||||
if (requested && roasts.some((roast) => roast.id === requested))
|
||||
await openDetail(requested, { keepScroll: true });
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user