Test and deploy / test-and-deploy (push) Successful in 1m0s
Machines: - roasters table + actual_roasts.roaster_id (migrations 009): per-user machines with a default; uploads attach to the default roaster and are reassignable from the roast detail view - Learning is now per machine: /api/roaster-profile scopes to a roaster (default when unspecified) and applies the user's override tweaks (milestone temps, TP time, pace factor) on top of learned medians - /roasters page: machine list + analysis report showing learned vs applied values with editable tweaks, typical RoR, and a plain-language list of the adjustments applied to plans; planner honors a manual pace override; evaluations use the roast's own machine profile Mobile header redesign: - The planner's header tools collapse behind a single 'Tools ▾' disclosure (dropdown card) at ≤720px, keeping the fixed three-row mobile header with no wrapped or scrolling button rows; desktop renders inline via display:contents (same DOM, same handlers) Profile pictures: - users.avatar (migration 010) + PUT/GET/DELETE /api/account/avatar; client-side centre-crop resize to 128px JPEG; avatar shows in the nav chip on every page; included in full backups Co-Authored-By: Claude Fable 5 <[email protected]>
289 lines
9.7 KiB
JavaScript
289 lines
9.7 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")}`;
|
||
|
||
// 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);
|
||
}
|
||
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();
|