1004 lines
29 KiB
JavaScript
1004 lines
29 KiB
JavaScript
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();
|
||
});
|