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
@@ -24,6 +24,10 @@
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/roasts"
|
||||
><span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Roasts</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/roasts"
|
||||
><span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Roasts</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/roasts"
|
||||
><span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Roasts</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span>
|
||||
</button>
|
||||
<a class="nav-item" href="/roasts"
|
||||
><span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Roasts</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
@@ -219,6 +223,23 @@
|
||||
</button>
|
||||
<ul class="lib-list hidden" id="alog-library-list"></ul>
|
||||
<div id="alog-result" class="drawer-result"></div>
|
||||
<hr style="border:none;border-top:1px solid var(--line,#e5dcd2);margin:16px 0" />
|
||||
<h4 style="margin:0 0 6px">Log a finished roast</h4>
|
||||
<p class="field-note" style="margin-top:0">
|
||||
Upload the actual roast's .alog to attach it to this plan — you can
|
||||
upload several. The Pi agent reviews each one in the background.
|
||||
</p>
|
||||
<label class="filebtn primary-btn"
|
||||
>Upload finished roast(s)<input
|
||||
type="file"
|
||||
id="actual-alog-file"
|
||||
accept=".alog"
|
||||
multiple
|
||||
/></label>
|
||||
<div id="actual-alog-result" class="drawer-result"></div>
|
||||
<p class="field-note" style="margin-bottom:0">
|
||||
<a href="/roasts">View roast history →</a>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/roasts"
|
||||
><span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Roasts</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
|
||||
+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();
|
||||
@@ -0,0 +1,169 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Roast history — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/roasts" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Roasts</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Roast history</h1>
|
||||
<p class="brand-sub">
|
||||
Finished roasts uploaded from Artisan, reviewed by the Pi agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="roasts">
|
||||
<div class="panel-head">
|
||||
<h2>Actual roasts</h2>
|
||||
<label class="filebtn ghost-btn small"
|
||||
>Upload .alog<input
|
||||
type="file"
|
||||
id="roast-upload"
|
||||
accept=".alog"
|
||||
multiple
|
||||
/></label>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="field-note" id="upload-status" hidden></p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="roasts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Roast</th>
|
||||
<th>Plan</th>
|
||||
<th>First crack</th>
|
||||
<th>Drop</th>
|
||||
<th>DTR</th>
|
||||
<th>Loss</th>
|
||||
<th>Review</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="roasts-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="field-note">
|
||||
Upload finished roasts here (they stay unattached to a plan), or
|
||||
from the planner's Reference curve drawer to attach them to the
|
||||
open plan. Click a row for the full review and curve.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card hidden" id="roast-detail-card">
|
||||
<div class="panel-head">
|
||||
<h2 id="detail-title">Roast detail</h2>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a class="ghost-btn small" id="detail-download" href="#" download
|
||||
>Download original .alog</a
|
||||
>
|
||||
<button class="ghost-btn small" type="button" id="detail-reevaluate">
|
||||
Re-evaluate
|
||||
</button>
|
||||
<button class="ghost-btn small" type="button" id="detail-delete">
|
||||
Delete
|
||||
</button>
|
||||
<button class="ghost-btn small" type="button" id="detail-close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="field-note" id="detail-meta"></p>
|
||||
<div class="table-wrap">
|
||||
<svg
|
||||
id="roast-graph"
|
||||
viewBox="0 0 714 360"
|
||||
style="width:100%;max-width:900px;display:block"
|
||||
role="img"
|
||||
aria-label="Bean temperature curve of this roast"
|
||||
></svg>
|
||||
</div>
|
||||
<div id="detail-stats" class="field-grid" style="margin-top:10px"></div>
|
||||
<div id="detail-evaluation" style="margin-top:14px"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/roasts.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user