Files
roast_command_center/public/js/roasts.js
T
snowspeeder 8c21750356
Test and deploy / test-and-deploy (push) Successful in 1m21s
Build coffee history and experiment workflows with Ask Bean Bro
2026-09-06 18:43:33 -04:00

1004 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<tr><td colspan="6" class="empty-state">No roasts match. Adjust the filters or upload a finished roast.</td></tr>`;
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 =
`<tr><td colspan="6" 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
// Bean Bro 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", {}, "Bean Bro review");
heading.style.margin = "0 0 6px";
box.append(heading);
if (detail.evaluationStatus === "pending") {
box.append(
el(
"p",
{ className: "field-note" },
"Bean Bro 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" },
detail.roast?.manual ? "Manual milestones recorded. Add tasting feedback below; these points do not provide a continuous RoR trace." : "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 ?? "";
if (!detail.roastPlanId) {
const words=String(detail.roast?.title||detail.filename).toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>3);
const ranked=planList.map(p=>({p,score:words.filter(w=>String(p.coffee||p.title).toLowerCase().includes(w)).length})).filter(r=>r.score>=2).sort((a,b)=>b.score-a.score);
if(ranked.length){const suggested=ranked[0].p;const button=el("button",{type:"button",className:"ghost-btn small"},`Suggested match: ${suggested.title} — attach`);button.addEventListener("click",()=>{planSelect.value=suggested.id;planSelect.dispatchEvent(new Event("change"));});planWrap.append(button);}
}
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").hidden=Boolean(detail.roast?.manual);
document.getElementById("detail-download").textContent=detail.roast?.manual?"Download manual record":"Download original .alog";
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
? `${detail.hasPlanSnapshot?"Saved target snapshot":"Current linked plan (no historical snapshot)"}. * Illustrative 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 = ["tasteBalance", "sweetness", "favorite", "experimentResult",
"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 ?? {};
document.getElementById("experiment-expectation").textContent = detail.plan?.experiment?.expected ? "Expected result: " + detail.plan.experiment.expected : "Record one change and what you expect to taste in the plan’s experiment section.";
// 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");
if(button.disabled)return;
const submitButtons=[...form.querySelectorAll('button[type="submit"]')];
submitButtons.forEach(b=>b.disabled=true);
try {
await api(`/api/roasts/${openId}`, {
method: "PUT",
body: JSON.stringify({ after }),
});
afterFormDirty = false;
showToast("Tasting and roast notes saved.");
} catch (error) {
showToast(error.message, "fail");
} finally {
submitButtons.forEach(b=>b.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, newestId = null;
for (const file of files) {
status.textContent = `Uploading ${file.name}…`;
try {
const uploadedRoast = await api("/api/roasts", {
method: "POST",
body: JSON.stringify({
filename: file.name,
content: await file.text(),
}),
});
newestId = uploadedRoast.roast.id;
uploaded++;
} catch (error) {
showToast(`${file.name}: ${error.message}`, "fail");
}
}
status.textContent = uploaded
? `Uploaded ${uploaded} roast${uploaded === 1 ? "" : "s"} — Bean Bro review runs in the background.`
: "";
status.hidden = !status.textContent;
await loadRoasts();
if (newestId) await openDetail(newestId);
}
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();
});