247 lines
7.9 KiB
JavaScript
247 lines
7.9 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__";
|
|
|
|
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")}`;
|
|
|
|
// Descriptive history only; machine statistics never modify plans.
|
|
const METRICS = [
|
|
{ key: "chargeTempC", label: "Charge BT", unit: "°F" },
|
|
{ key: "turningPointS", label: "Turning point time", unit: "", fmt: fmtTime },
|
|
{ key: "turningPointTempC", label: "Turning point BT", unit: "°F" },
|
|
{ key: "yellowTempC", label: "Yellow BT", unit: "°F" },
|
|
{ key: "firstCrackTempC", label: "First crack BT", unit: "°F" },
|
|
{ key: "dropTempC", label: "Drop BT", unit: "°F" },
|
|
];
|
|
|
|
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 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) : Math.round(learnedValue * 1.8 + 32)} ${metric.fmt ? "" : metric.unit}`.trim();
|
|
|
|
tr.append(label, learnedCell);
|
|
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 = ["Historical measurements only. Selecting a default roaster organizes uploads; it does not change roast plans."];
|
|
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 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("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();
|