Test and deploy / test-and-deploy (push) Successful in 53s
Academy (/academy, 'Learn' nav group): - 10 lessons / 45 slides across two tracks. Roasting: seed anatomy and composition, drying & the turning point, Maillard, first crack & development (DTR), curve/RoR reading (crash, flick, stall), defects → one-change discipline. Brewing: extraction physics (dissolving order, yield, grind, temp/time/ratio), then technique per brewer family — immersion (French press, AeroPress, Clever/Hario Switch, cold brew, cupping), percolation (bloom, V60, Chemex/Kalita, batch, percolator), espresso & pressure (puck prep, shot reading, dialing in, moka) - One cohesive scene system: 25 parameterized animated SVG scenes drawn from the app's palette (beans, curves, phase bars, brewers reusing the silhouette library), CSS keyframe animations, reduced-motion support - Narration: Azure TTS (en-US-Andrew HD, eastus) pre-generated to 45 committed MP3s by scripts/generate-academy-audio.mjs; slides speak phonetic respellings (my-YARD, KEM-ex, MOH-kah…) while captions show normal spelling; hands-free autoplay advances after each clip - Player: slide dots, keyboard arrows/space, per-slide captions Roast detail: 'Open cupping session' button next to cup notes (opens or creates the linked plan's session) — its score and flavors flow into the updated .alog download. Co-Authored-By: Claude Fable 5 <[email protected]>
590 lines
20 KiB
JavaScript
590 lines
20 KiB
JavaScript
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
|
import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__";
|
|
import { buildPlanCurve } from "/shared/curve.js?v=__ASSET_VERSION__";
|
|
|
|
let roasts = [];
|
|
let openId = null;
|
|
let pollTimer = null;
|
|
let machineProfile = null;
|
|
let roasterList = [];
|
|
let planList = [];
|
|
|
|
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") === "F" ? "F" : "C";
|
|
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");
|
|
if (!roasts.length) {
|
|
body.innerHTML = `<tr><td colspan="6" class="empty-state">No finished roasts uploaded yet. Upload an Artisan .alog above, or from the planner's Reference curve drawer to attach it to a plan.</td></tr>`;
|
|
return;
|
|
}
|
|
body.replaceChildren(
|
|
...roasts.map((roast) => {
|
|
const tr = document.createElement("tr");
|
|
tr.style.cursor = "pointer";
|
|
if (roast.id === openId) tr.style.background = "var(--paper-2, #f4ede6)";
|
|
|
|
const roastCell = document.createElement("td");
|
|
const strong = document.createElement("strong");
|
|
strong.textContent = roast.roast?.title || roast.filename;
|
|
const sub = document.createElement("div");
|
|
sub.className = "muted";
|
|
sub.style.fontSize = "11.5px";
|
|
sub.textContent = [
|
|
roast.roast?.roastDate || fmtDate(roast.createdAt),
|
|
roast.roasterName,
|
|
roast.filename,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
roastCell.append(strong, sub);
|
|
|
|
const planCell = document.createElement("td");
|
|
planCell.textContent = roast.planTitle || "—";
|
|
|
|
const fcCell = document.createElement("td");
|
|
fcCell.textContent = fmtTime(roast.derived?.firstCrackS);
|
|
const dropCell = document.createElement("td");
|
|
dropCell.textContent = fmtTime(roast.derived?.dropS);
|
|
const dtrCell = document.createElement("td");
|
|
dtrCell.textContent =
|
|
roast.derived?.dtrPct == null ? "—" : `${roast.derived.dtrPct}%`;
|
|
const lossCell = document.createElement("td");
|
|
lossCell.textContent =
|
|
roast.roast?.weightLossPct == null ? "—" : `${roast.roast.weightLossPct}%`;
|
|
|
|
tr.append(roastCell, planCell, fcCell, dropCell, dtrCell, lossCell);
|
|
tr.addEventListener("click", () => openDetail(roast.id));
|
|
return tr;
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function loadRoasts() {
|
|
try {
|
|
roasts = (await api("/api/roasts")).roasts;
|
|
renderTable();
|
|
schedulePoll();
|
|
} catch {
|
|
document.getElementById("roasts-body").innerHTML =
|
|
`<tr><td colspan="7" class="empty-state">Could not load roasts.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
// While any review is still pending, refresh every few seconds so the table/detail fill in as
|
|
// the LLM finishes; stops by itself once nothing is pending.
|
|
function schedulePoll() {
|
|
clearTimeout(pollTimer);
|
|
if (!roasts.some((roast) => roast.evaluationStatus === "pending")) return;
|
|
pollTimer = setTimeout(async () => {
|
|
await loadRoasts();
|
|
if (openId) await openDetail(openId, { keepScroll: true });
|
|
}, 4000);
|
|
}
|
|
|
|
// ---- detail view ------------------------------------------------------------
|
|
|
|
function el(tag, props = {}, text) {
|
|
const node = document.createElement(tag);
|
|
Object.assign(node, props);
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function svgEl(tag, attrs) {
|
|
const node = document.createElementNS(SVG_NS, tag);
|
|
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
|
return node;
|
|
}
|
|
|
|
function renderGraph(detail) {
|
|
const svg = document.getElementById("roast-graph");
|
|
svg.replaceChildren();
|
|
const curve = detail.parsed?.curve ?? [];
|
|
if (!curve.length) {
|
|
svg.append(
|
|
svgEl("text", { x: 357, y: 180, "text-anchor": "middle", fill: "#5a5048", "font-size": 14 }),
|
|
);
|
|
svg.lastChild.textContent = "No telemetry in this log.";
|
|
return;
|
|
}
|
|
|
|
// Plan overlay points (same shared math as the planner page), if a plan is attached.
|
|
let planPoints = [];
|
|
if (detail.plan) {
|
|
try {
|
|
const ledger = computeLedger(detail.plan, machineProfile);
|
|
planPoints = buildPlanCurve(detail.plan, ledger).filter(
|
|
(p) => p.timeS != null && p.tempC != null,
|
|
);
|
|
} catch {
|
|
planPoints = [];
|
|
}
|
|
}
|
|
|
|
const x0 = 52,
|
|
x1 = 692,
|
|
y0 = 24,
|
|
y1 = 300;
|
|
const lastT = Math.max(
|
|
curve[curve.length - 1].t,
|
|
...planPoints.map((p) => p.timeS),
|
|
540,
|
|
);
|
|
const tMax = Math.ceil(lastT / 60) * 60;
|
|
const temps = curve.map((p) => p.bt).concat(planPoints.map((p) => p.tempC));
|
|
const tempLo = Math.min(60, Math.floor((Math.min(...temps) - 10) / 20) * 20);
|
|
const tempHi = Math.max(220, Math.ceil((Math.max(...temps) + 10) / 20) * 20);
|
|
const xFor = (t) => x0 + (Math.max(0, Math.min(tMax, t)) / tMax) * (x1 - x0);
|
|
const yFor = (c) => y1 - ((c - tempLo) / (tempHi - tempLo)) * (y1 - y0);
|
|
|
|
// Grid + axis labels
|
|
const minuteStep = tMax > 900 ? 120 : 60;
|
|
for (let t = 0; t <= tMax; t += minuteStep) {
|
|
svg.append(
|
|
svgEl("line", { x1: xFor(t), y1: y0, x2: xFor(t), y2: y1, stroke: "#e5dcd2", "stroke-width": t === 0 ? 1.2 : 0.7 }),
|
|
);
|
|
const label = svgEl("text", { x: xFor(t), y: y1 + 16, "text-anchor": "middle", fill: "#8a7f74", "font-size": 10 });
|
|
label.textContent = `${t / 60}`;
|
|
svg.append(label);
|
|
}
|
|
for (let c = tempLo; c <= tempHi; c += 20) {
|
|
svg.append(
|
|
svgEl("line", { x1: x0, y1: yFor(c), x2: x1, y2: yFor(c), stroke: "#e5dcd2", "stroke-width": c === tempLo ? 1.2 : 0.7 }),
|
|
);
|
|
const label = svgEl("text", { x: x0 - 6, y: yFor(c) + 3, "text-anchor": "end", fill: "#8a7f74", "font-size": 10 });
|
|
label.textContent = String(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", {}, "LLM review");
|
|
heading.style.margin = "0 0 6px";
|
|
box.append(heading);
|
|
if (detail.evaluationStatus === "pending") {
|
|
box.append(el("p", { className: "field-note" }, "The LLM is reviewing this roast — this page refreshes automatically."));
|
|
return;
|
|
}
|
|
if (detail.evaluationStatus === "failed") {
|
|
const p = el(
|
|
"p",
|
|
{ className: "field-note" },
|
|
`Review failed${detail.evaluationError ? ` (${detail.evaluationError})` : ""}. Use Re-evaluate to try again.`,
|
|
);
|
|
p.style.color = "#a8371a";
|
|
box.append(p);
|
|
return;
|
|
}
|
|
const ev = detail.evaluation;
|
|
if (!ev) {
|
|
box.append(el("p", { className: "field-note" }, "No review recorded for this roast."));
|
|
return;
|
|
}
|
|
const grade = el("p", {}, `Grade: ${GRADE_LABEL[ev.grade] ?? ev.grade}`);
|
|
grade.style.fontWeight = "600";
|
|
box.append(grade, el("p", {}, ev.summary));
|
|
if (ev.planComparison) box.append(evaluationList("Against the plan", [ev.planComparison]));
|
|
if (ev.highlights?.length) box.append(evaluationList("What went well", ev.highlights));
|
|
if (ev.concerns?.length) box.append(evaluationList("Concerns", ev.concerns));
|
|
if (ev.suggestions?.length) box.append(evaluationList("Next batch", ev.suggestions));
|
|
if (detail.parsed?.warnings?.length)
|
|
box.append(evaluationList("Parser notes", detail.parsed.warnings));
|
|
}
|
|
|
|
async function openDetail(id, { keepScroll = false } = {}) {
|
|
let detail;
|
|
try {
|
|
detail = (await api(`/api/roasts/${id}`)).roast;
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
return;
|
|
}
|
|
openId = id;
|
|
history.replaceState(null, "", `/roasts?roast=${encodeURIComponent(id)}`);
|
|
const card = document.getElementById("roast-detail-card");
|
|
card.classList.remove("hidden");
|
|
document.getElementById("detail-title").textContent =
|
|
detail.roast?.title || detail.filename;
|
|
const meta = document.getElementById("detail-meta");
|
|
meta.textContent = [
|
|
detail.roast?.roastDate || fmtDate(detail.createdAt),
|
|
detail.roast?.roasterType,
|
|
detail.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.plan?.fields?.["0.1"] || "Untitled plan",
|
|
}),
|
|
),
|
|
);
|
|
planSelect.value = detail.roastPlanId ?? "";
|
|
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");
|
|
}
|
|
});
|
|
planWrap.append(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").href =
|
|
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
|
|
renderAfterForm(detail);
|
|
renderGraph(detail);
|
|
renderStats(detail);
|
|
renderEvaluation(detail);
|
|
renderTable();
|
|
if (!keepScroll) card.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
// ---- after the roast (moved here from the plan worksheet) -------------------
|
|
|
|
const AFTER_FIELDS = [
|
|
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
|
|
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
|
|
];
|
|
|
|
function renderAfterForm(detail) {
|
|
const form = document.getElementById("after-form");
|
|
const after = detail.after ?? {};
|
|
// Prefill measurable values straight from the .alog so the user only types what the
|
|
// machine couldn't know (colour, cup notes, the one change).
|
|
const auto = {
|
|
greenIn: detail.parsed?.roast?.weightInG || "",
|
|
out: detail.parsed?.roast?.weightOutG || "",
|
|
weightLossPct: detail.parsed?.roast?.weightLossPct ?? "",
|
|
actualDtrPct: detail.parsed?.derived?.dtrPct ?? "",
|
|
};
|
|
for (const field of AFTER_FIELDS)
|
|
form[field].value = after[field] ?? (auto[field] === 0 ? "" : (auto[field] ?? ""));
|
|
}
|
|
|
|
document.getElementById("after-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
if (!openId) return;
|
|
const form = event.target;
|
|
const after = {};
|
|
for (const field of AFTER_FIELDS) after[field] = form[field].value;
|
|
const button = document.getElementById("after-save");
|
|
button.disabled = true;
|
|
try {
|
|
await api(`/api/roasts/${openId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ after }),
|
|
});
|
|
showToast("After-roast saved — included in the updated .alog.");
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
// 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() {
|
|
openId = null;
|
|
history.replaceState(null, "", "/roasts");
|
|
document.getElementById("roast-detail-card").classList.add("hidden");
|
|
renderTable();
|
|
}
|
|
|
|
// ---- uploads ----------------------------------------------------------------
|
|
|
|
async function uploadFiles(files) {
|
|
const status = document.getElementById("upload-status");
|
|
status.hidden = false;
|
|
let uploaded = 0;
|
|
for (const file of files) {
|
|
status.textContent = `Uploading ${file.name}…`;
|
|
try {
|
|
await api("/api/roasts", {
|
|
method: "POST",
|
|
body: JSON.stringify({ filename: file.name, content: await file.text() }),
|
|
});
|
|
uploaded++;
|
|
} catch (error) {
|
|
showToast(`${file.name}: ${error.message}`, "fail");
|
|
}
|
|
}
|
|
status.textContent = uploaded
|
|
? `Uploaded ${uploaded} roast${uploaded === 1 ? "" : "s"} — the LLM review runs in the background.`
|
|
: "";
|
|
status.hidden = !status.textContent;
|
|
await loadRoasts();
|
|
}
|
|
|
|
document.getElementById("roast-upload").addEventListener("change", async (event) => {
|
|
const files = [...(event.target.files ?? [])];
|
|
event.target.value = "";
|
|
if (files.length) await uploadFiles(files);
|
|
});
|
|
|
|
document.getElementById("detail-close").addEventListener("click", closeDetail);
|
|
document.getElementById("detail-reevaluate").addEventListener("click", async (event) => {
|
|
if (!openId || event.currentTarget.disabled) return;
|
|
const button = event.currentTarget;
|
|
button.disabled = true;
|
|
try {
|
|
await api(`/api/roasts/${openId}/evaluate`, { method: "POST" });
|
|
showToast("Re-evaluation queued.");
|
|
await loadRoasts();
|
|
await openDetail(openId, { keepScroll: true });
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
document.getElementById("detail-delete").addEventListener("click", async () => {
|
|
if (!openId) return;
|
|
const roast = roasts.find((r) => r.id === openId);
|
|
const name = roast?.roast?.title || roast?.filename || "this roast";
|
|
if (!confirm(`Delete "${name}" and its stored .alog? This cannot be undone.`)) return;
|
|
try {
|
|
await api(`/api/roasts/${openId}`, { method: "DELETE" });
|
|
showToast("Roast deleted.");
|
|
closeDetail();
|
|
await loadRoasts();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
|
|
document.getElementById("btn-logout").addEventListener("click", async () => {
|
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
|
location.assign("/");
|
|
});
|
|
|
|
async function init() {
|
|
initSideNav();
|
|
const user = await loadNavUser();
|
|
if (!user) return;
|
|
// Same learned pace the planner uses, so the dashed plan overlay matches the planner's curve.
|
|
try {
|
|
machineProfile = (await api("/api/machine-profile")).profile;
|
|
} catch {
|
|
machineProfile = null;
|
|
}
|
|
try {
|
|
roasterList = (await api("/api/roasters")).roasters;
|
|
} catch {
|
|
roasterList = [];
|
|
}
|
|
try {
|
|
planList = (await api("/api/plans")).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();
|