Files
roast_command_center/public/js/roasts.js
T
Shane MaynardandClaude Fable 5 4a3d192c2c
Test and deploy / test-and-deploy (push) Successful in 56s
Move After-the-Roast onto actual roasts; add Artisan-importable updated .alog download
One system: post-roast observations (weights, colour, DTR, cup notes,
'one change next batch', disproof) now live on the uploaded roast
(actual_roasts.after, migration 011), edited on the roast detail view
with weights/DTR prefilled from the .alog itself. The planner's screen
section is removed (the print worksheet keeps its hand-fill copy), and
the lot 'last refine' suggestion reads the note from both the new home
and legacy plans, newest wins.

Every roast now downloads two ways: the untouched original .alog, and
an updated supplemental copy with the app's data written back as valid
Artisan fields (weight, beans, roastingnotes incl. the LLM review,
cuppingnotes incl. linked cupping score/flavors) — serialized as a
Python literal so Artisan's ast.literal_eval re-imports it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-09 08:35:11 -04:00

511 lines
17 KiB
JavaScript

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 = [];
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 renderTable() {
const body = document.getElementById("roasts-body");
if (!roasts.length) {
body.innerHTML = `<tr><td colspan="6" 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.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 =
`<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 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(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", {}, "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.planTitle ? `Plan: ${detail.planTitle}` : "No plan attached",
detail.filename,
]
.filter(Boolean)
.join(" · ");
// 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 = [];
}
await loadRoasts();
const requested = new URLSearchParams(location.search).get("roast");
if (requested && roasts.some((roast) => roast.id === requested))
await openDetail(requested, { keepScroll: true });
}
init();