Files
snowspeederandClaude Fable 5 385474ecef
Test and deploy / test-and-deploy (push) Successful in 1m8s
Removable reference curves, audit fixes across pages, shaded Academy art
The planner's Artisan .alog drawer now shows what's attached with a
"Remove reference curve" control (re-rendered per drawer open), so adding
a reference curve is no longer a one-way door; /api/alog shares the 8 MB
body cap so real-sized logs parse instead of failing with "bad_request".

Deep-evaluation fixes: editing a brew of an archived bean no longer
silently detaches the bean; the roasts pending-review poll no longer
wipes in-progress after-roast edits; roasters gain an Edit (rename/model)
action; the gear page refuses to autosave over a failed load; duplicating
a plan carries its custom name; cupping sessions can attach a plan after
creation (ownership-checked PUT + selector); admin user deletion also
refreshes plans/audit; cupping cup-count subtitle stays live; roasts
error-row colspan corrected. Regression tests cover the new cupping PUT
and the /api/alog body cap.

Academy scenes drop the flat paper-cutout look: shared defs provide
radial-gradient shading on every bean/half-bean/particle, flame gradients
with radiant halos, soft ground shadows, and a warm-lit stage background;
fill-shift animations now ride a partial-opacity tint overlay so shading
survives the color change.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-11 16:59:28 -04:00

318 lines
11 KiB
JavaScript
Raw Permalink 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 { 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__";
let roasters = [];
let selectedId = null;
let profile = null;
const fmtTime = (s) =>
s == null
? "—"
: `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`;
// Metric rows of the report: what the planner actually consumes from a machine's profile.
// `fmt` renders read-only values; overrides are edited in the raw unit (°C / seconds / ×).
const METRICS = [
{ key: "chargeTempC", label: "Charge temp (curve fallback)", unit: "°C" },
{ key: "turningPointS", label: "Turning point time (thermal lag)", unit: "s", fmt: fmtTime },
{ key: "turningPointTempC", label: "Turning point temp", unit: "°C" },
{ key: "yellowTempC", label: "Yellow temp (curve fallback)", unit: "°C" },
{ key: "firstCrackTempC", label: "First crack temp (curve fallback)", unit: "°C" },
{ key: "dropTempC", label: "Drop temp (curve fallback)", unit: "°C" },
{ key: "paceFactor", label: "Pace factor (multiplies first-crack timing)", unit: "×" },
];
function renderRoasters() {
const body = document.getElementById("roasters-body");
if (!roasters.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No roasters yet — add your machine below. Your first one becomes the default.</td></tr>`;
return;
}
body.replaceChildren(
...roasters.map((roaster) => {
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
if (roaster.id === selectedId) tr.style.background = "var(--surface-sunken)";
const nameCell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = roaster.name;
nameCell.append(strong);
if (roaster.model) {
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent = roaster.model;
nameCell.append(sub);
}
const defaultCell = document.createElement("td");
defaultCell.textContent = roaster.isDefault ? "✓ default" : "";
const countCell = document.createElement("td");
countCell.className = "num";
countCell.textContent = roaster.roastCount ?? 0;
const actions = document.createElement("td");
actions.className = "data-table-actions";
if (!roaster.isDefault) {
const makeDefault = document.createElement("button");
makeDefault.className = "ghost-btn small";
makeDefault.type = "button";
makeDefault.textContent = "Make default";
makeDefault.addEventListener("click", async (event) => {
event.stopPropagation();
try {
await api(`/api/roasters/${roaster.id}`, {
method: "PUT",
body: JSON.stringify({ isDefault: true }),
});
showToast(`${roaster.name} is now the default.`);
await loadRoasters();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(makeDefault);
}
// Rename/model edits reuse the existing PUT — without this, a typo'd machine
// could only be deleted, which detaches every roast logged against it.
const editBtn = document.createElement("button");
editBtn.className = "ghost-btn small";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", async (event) => {
event.stopPropagation();
const name = prompt("Machine name:", roaster.name);
if (name == null) return;
if (!name.trim()) {
showToast("Name cannot be empty.", "fail");
return;
}
const model = prompt("Model (optional):", roaster.model ?? "");
if (model == null) return;
try {
await api(`/api/roasters/${roaster.id}`, {
method: "PUT",
body: JSON.stringify({ name: name.trim(), model: model.trim() }),
});
showToast("Roaster updated.");
await loadRoasters();
if (selectedId === roaster.id) renderReport();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", async (event) => {
event.stopPropagation();
if (
!confirm(
`Delete "${roaster.name}"? Its logged roasts are kept but detach from it.`,
)
)
return;
try {
await api(`/api/roasters/${roaster.id}`, { method: "DELETE" });
showToast("Roaster deleted.");
if (selectedId === roaster.id) selectedId = null;
await loadRoasters();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(deleteBtn);
tr.append(nameCell, defaultCell, countCell, actions);
tr.addEventListener("click", () => selectRoaster(roaster.id));
return tr;
}),
);
}
function overrideInputs() {
return [...document.querySelectorAll("#report-rows input[data-key]")];
}
function renderReport() {
const card = document.getElementById("report-card");
if (!selectedId || !profile) {
card.classList.add("hidden");
return;
}
card.classList.remove("hidden");
const roaster = roasters.find((r) => r.id === selectedId);
document.getElementById("report-title").textContent =
`Analysis — ${roaster?.name ?? ""}`;
document.getElementById("report-count").textContent =
`${profile.n} roast${profile.n === 1 ? "" : "s"} analyzed`;
document.getElementById("report-empty").hidden = profile.n > 0;
const rows = document.getElementById("report-rows");
rows.replaceChildren(
...METRICS.map((metric) => {
const tr = document.createElement("tr");
const label = document.createElement("td");
label.textContent = metric.label;
const learnedCell = document.createElement("td");
const learnedValue =
metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key];
learnedCell.textContent =
learnedValue == null
? metric.key === "paceFactor"
? "learned from plan logs"
: "—"
: `${metric.fmt ? metric.fmt(learnedValue) : learnedValue} ${metric.fmt ? "" : metric.unit}`.trim();
const overrideCell = document.createElement("td");
const input = document.createElement("input");
input.className = "field-input sm";
input.type = "number";
input.step = "any";
input.style.maxWidth = "110px";
input.dataset.key = metric.key;
input.placeholder = metric.unit;
const overrideValue = profile.overrides?.[metric.key];
if (overrideValue != null) input.value = overrideValue;
overrideCell.append(input);
const appliedCell = document.createElement("td");
const applied =
overrideValue ??
(metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key]);
appliedCell.textContent =
applied == null
? metric.key === "paceFactor"
? "plan-log pace"
: "reference default"
: `${metric.fmt ? metric.fmt(applied) : applied} ${metric.fmt ? "" : metric.unit}${overrideValue != null ? " (your tweak)" : " (learned)"}`.trim();
tr.append(label, learnedCell, overrideCell, appliedCell);
return tr;
}),
);
const ror = profile.rorCPerMin ?? {};
document.getElementById("report-ror").textContent =
profile.n > 0
? `Typical rate of rise on this machine — drying ${ror.drying ?? "—"} °C/min, Maillard ${ror.maillard ?? "—"} °C/min, development ${ror.development ?? "—"} °C/min.`
: "";
const adjustments = document.getElementById("report-adjustments");
adjustments.replaceChildren();
const list = document.createElement("ul");
list.style.paddingLeft = "18px";
list.style.fontSize = "13px";
const items = [];
if (profile.n > 0)
items.push(
"Blank milestone temps on the planner's curve use this machine's learned medians instead of the reference machine.",
`New plans assume the turning point around ${fmtTime(profile.medians?.turningPointS)} — this machine's measured thermal lag.`,
);
if (profile.paceFactorOverride != null)
items.push(
`First-crack timing is multiplied by your pace tweak ×${profile.paceFactorOverride} instead of the plan-log learned pace.`,
);
items.push(
"Roast reviews compare each new roast against this machine's own typical behavior, not a generic one.",
);
if (!(roaster?.isDefault ?? false))
items.push(
"Note: the planner uses the DEFAULT machine's profile — make this one default to plan with it.",
);
for (const text of items) {
const li = document.createElement("li");
li.textContent = text;
list.append(li);
}
adjustments.append(list);
}
async function selectRoaster(id) {
selectedId = id;
try {
profile = (await api(`/api/roaster-profile?roaster=${encodeURIComponent(id)}`)).profile;
} catch (error) {
showToast(error.message, "fail");
profile = null;
}
renderRoasters();
renderReport();
}
async function saveOverrides(clear = false) {
if (!selectedId) return;
const overrides = {};
if (!clear)
for (const input of overrideInputs()) {
if (input.value === "") continue;
const value = Number(input.value);
if (!Number.isFinite(value)) {
showToast(`"${input.value}" is not a number.`, "fail");
return;
}
overrides[input.dataset.key] = value;
}
try {
await api(`/api/roasters/${selectedId}`, {
method: "PUT",
body: JSON.stringify({ overrides }),
});
showToast(clear ? "Tweaks cleared — using learned values." : "Tweaks saved.");
await loadRoasters();
await selectRoaster(selectedId);
} catch (error) {
showToast(error.message, "fail");
}
}
async function loadRoasters() {
try {
roasters = (await api("/api/roasters")).roasters;
renderRoasters();
} catch {
document.getElementById("roasters-body").innerHTML =
`<tr><td colspan="4" class="empty-state">Could not load roasters.</td></tr>`;
}
}
document.getElementById("roaster-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.target;
try {
const created = await api("/api/roasters", {
method: "POST",
body: JSON.stringify({ name: form.name.value, model: form.model.value }),
});
form.reset();
showToast("Roaster added.");
await loadRoasters();
await selectRoaster(created.roaster.id);
} catch (error) {
showToast(error.message, "fail");
}
});
document.getElementById("save-overrides").addEventListener("click", () => saveOverrides(false));
document.getElementById("clear-overrides").addEventListener("click", () => saveOverrides(true));
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;
await loadRoasters();
const preferred = roasters.find((r) => r.isDefault) ?? roasters[0];
if (preferred) await selectRoaster(preferred.id);
}
init();