Add roaster (machine) management, mobile header-tools redesign, and profile pictures
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]>
This commit is contained in:
Shane Maynard
2026-08-09 08:17:10 -04:00
co-authored by Claude Fable 5
parent e62b9601a2
commit a341d67467
15 changed files with 1291 additions and 62 deletions
+23
View File
@@ -65,6 +65,29 @@
<h2>Profile</h2>
</div>
<div class="panel-body">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px">
<div
class="nav-user-avatar"
id="profile-avatar"
style="width:64px;height:64px;font-size:24px;flex:none"
aria-hidden="true"
></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<label class="filebtn ghost-btn small"
>Upload picture…<input
type="file"
id="avatar-file"
accept="image/png,image/jpeg,image/webp"
/></label>
<button
class="ghost-btn small hidden"
type="button"
id="avatar-remove"
>
Remove
</button>
</div>
</div>
<p>
<strong id="profile-email"></strong>
<span class="badge badge-admin hidden" id="profile-role-admin"
+57 -1
View File
@@ -3001,10 +3001,66 @@ select.f {
min-height: 40px;
}
/* ── Planner header tools (page-scoped actions moved out of the left nav) ── */
/* ── Planner header tools (page-scoped actions moved out of the left nav) ──
Desktop: the tools render inline in the header actions row (display: contents).
≤720px: they collapse behind a single "Tools ▾" disclosure that opens a dropdown card, so
the mobile header keeps its fixed three-row height (--header-h) with no wrapped or
horizontally scrolling button rows. */
.header-tools {
flex-wrap: wrap;
justify-content: flex-end;
row-gap: 6px;
max-width: 100%;
position: relative;
}
.header-tools-toggle {
display: none;
}
.header-tools-group {
display: contents;
}
@media (max-width: 720px) {
.header-tools {
flex-wrap: nowrap;
}
.header-tools-toggle {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.header-tools-toggle[aria-expanded="true"] {
background: var(--ember-soft);
border-color: var(--ember);
color: var(--ember-strong);
}
.header-tools-group {
display: none;
}
.header-tools-group.open {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 70;
min-width: 220px;
padding: 10px;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-md);
box-shadow: var(--shadow-pop);
}
.header-tools-group .ghost-btn {
justify-content: flex-start;
text-align: left;
width: 100%;
}
.header-tools-group .header-divider {
display: block;
width: 100%;
height: 1px;
margin: 2px 0;
}
}
+38 -27
View File
@@ -75,37 +75,48 @@
><span class="dot"></span
><span class="autosave-text">Not saved yet</span></span
>
<button id="nav-plans" type="button" class="ghost-btn small">
▤ Plans
</button>
<button id="nav-chat" type="button" class="ghost-btn small">
✳ Ask the LLM
</button>
<button id="nav-prefill" type="button" class="ghost-btn small">
↗ Prefill
</button>
<button id="nav-alog" type="button" class="ghost-btn small">
∿ Curves
</button>
<button id="nav-import-export" type="button" class="ghost-btn small">
⇅ Import / export
</button>
<button
id="btn-toggle-fids"
id="btn-tools-menu"
type="button"
class="ghost-btn small"
aria-pressed="false"
title="Show the worksheet reference codes (e.g. 1.4) on every field"
class="ghost-btn small header-tools-toggle"
aria-expanded="false"
aria-controls="header-tools-group"
>
# IDs
</button>
<button id="nav-settings" type="button" class="ghost-btn small">
◎ Settings
</button>
<span class="header-divider" aria-hidden="true"></span>
<button id="btn-expand-why" type="button" class="ghost-btn small">
Expand "why" notes
⚙ Tools ▾
</button>
<div class="header-tools-group" id="header-tools-group">
<button id="nav-plans" type="button" class="ghost-btn small">
▤ Plans
</button>
<button id="nav-chat" type="button" class="ghost-btn small">
✳ Ask the LLM
</button>
<button id="nav-prefill" type="button" class="ghost-btn small">
↗ Prefill
</button>
<button id="nav-alog" type="button" class="ghost-btn small">
∿ Curves
</button>
<button id="nav-import-export" type="button" class="ghost-btn small">
⇅ Import / export
</button>
<button
id="btn-toggle-fids"
type="button"
class="ghost-btn small"
aria-pressed="false"
title="Show the worksheet reference codes (e.g. 1.4) on every field"
>
# IDs
</button>
<button id="nav-settings" type="button" class="ghost-btn small">
◎ Settings
</button>
<span class="header-divider" aria-hidden="true"></span>
<button id="btn-expand-why" type="button" class="ghost-btn small">
Expand "why" notes
</button>
</div>
<button id="btn-print" type="button" class="primary-btn">
Print
</button>
+58 -1
View File
@@ -1,5 +1,5 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { applyAvatar, initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
function fmtDate(value) {
@@ -331,6 +331,62 @@ function wirePwaButtons() {
});
}
// Profile picture: resized client-side to a small square JPEG data URL before upload, so the
// server only ever stores a few tens of KB per user.
function wireAvatar(user) {
const refresh = (hasAvatar) => {
const shownUser = { ...user, hasAvatar };
applyAvatar(document.getElementById("profile-avatar"), shownUser, true);
applyAvatar(document.getElementById("nav-user-avatar"), shownUser, true);
document.getElementById("avatar-remove").classList.toggle("hidden", !hasAvatar);
};
refresh(user.hasAvatar);
document.getElementById("avatar-file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const bitmap = await createImageBitmap(file);
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d");
// Cover-crop the centre square so faces aren't squished.
const side = Math.min(bitmap.width, bitmap.height);
context.drawImage(
bitmap,
(bitmap.width - side) / 2,
(bitmap.height - side) / 2,
side,
side,
0,
0,
size,
size,
);
const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
await api("/api/account/avatar", {
method: "PUT",
body: JSON.stringify({ dataUrl }),
});
showToast("Profile picture updated.");
refresh(true);
} catch (error) {
showToast(error.message || "Could not read that image.", "fail");
}
});
document.getElementById("avatar-remove").addEventListener("click", async () => {
try {
await api("/api/account/avatar", { method: "DELETE" });
showToast("Profile picture removed.");
refresh(false);
} catch (error) {
showToast(error.message, "fail");
}
});
}
async function loadTokens() {
const body = document.getElementById("tokens-body");
try {
@@ -413,6 +469,7 @@ async function init() {
wireForms(user);
wirePwaButtons();
wireTokenForm();
wireAvatar(user);
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
+49 -5
View File
@@ -17,7 +17,7 @@ import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { applyAvatar, initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
@@ -47,6 +47,17 @@ async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
// A manual pace tweak on the default machine (Machines page) beats the plan-log
// learned pace in the ledger.
if (Number.isFinite(roasterProfile?.paceFactorOverride))
machineProfile = {
...(machineProfile ?? {}),
pace: {
value: roasterProfile.paceFactorOverride,
source: "override",
roasterName: roasterProfile.roaster?.name ?? null,
},
};
} catch {
/* offline — curve falls back to reference machine temps */
}
@@ -76,6 +87,10 @@ function renderMachineProfileNote() {
const el = document.getElementById("machine-profile-note");
if (!el) return;
const pace = machineProfile?.pace;
if (pace?.source === "override") {
el.textContent = `Pace ×${pace.value.toFixed(2)} — your manual tweak${pace.roasterName ? ` for ${pace.roasterName}` : ""} (Machines page).`;
return;
}
if (!pace || pace.source !== "learned") {
el.textContent =
"Using reference timing (one operator's Hottop KN-8828B-2K+) — recalibrates to your own machine as you log roasts with an actual first-crack time.";
@@ -926,6 +941,33 @@ async function deletePlan(plan) {
await loadPlans();
}
// Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders
// inline (display: contents). Any action inside closes it — every tool opens a drawer anyway.
function wireToolsMenu() {
const toggle = document.getElementById("btn-tools-menu");
const group = document.getElementById("header-tools-group");
if (!toggle || !group) return;
const close = () => {
group.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
};
toggle.addEventListener("click", (event) => {
event.stopPropagation();
const open = group.classList.toggle("open");
toggle.setAttribute("aria-expanded", String(open));
});
group.addEventListener("click", (event) => {
if (event.target.closest("button")) close();
});
document.addEventListener("click", (event) => {
if (!group.classList.contains("open")) return;
if (!group.contains(event.target) && event.target !== toggle) close();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
});
}
function wireToolbar() {
document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
const on = document.body.classList.toggle("show-fids");
@@ -1094,16 +1136,17 @@ async function init() {
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
document.getElementById("nav-user-avatar").textContent = user.email
.charAt(0)
.toUpperCase();
applyAvatar(document.getElementById("nav-user-avatar"), user);
if (user.role === "admin")
document.getElementById("nav-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await Promise.all([loadPlans(), loadMachineProfile(user.id), loadRoasterProfile()]);
// Roaster profile loads after the machine profile on purpose: its pace override (if
// any) must be applied on top of the freshly loaded machine profile, not raced with it.
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
await loadRoasterProfile();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
@@ -1153,6 +1196,7 @@ async function init() {
initSideNav();
wireDrawers();
wireToolbar();
wireToolsMenu();
wireTempUnitToggle();
updateTempUnitUI();
wirePwa();
+15 -1
View File
@@ -10,6 +10,7 @@ const NAV_GROUPS = [
items: [
{ href: "/app", icon: "◐", label: "Planner" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
{ href: "/roasters", icon: "♨", label: "Machines" },
{ href: "/inventory", icon: "▥", label: "Green lots" },
{ href: "/cupping", icon: "◒", label: "Cupping" },
],
@@ -112,6 +113,19 @@ export function initSideNav() {
return { closeMobileNav };
}
/** Renders a user's avatar into an element: their profile picture when set, else initial. */
export function applyAvatar(el, user, cacheBust = false) {
if (user.hasAvatar) {
el.textContent = "";
el.style.backgroundImage = `url("/api/account/avatar${cacheBust ? `?t=${Date.now()}` : ""}")`;
el.style.backgroundSize = "cover";
el.style.backgroundPosition = "center";
} else {
el.style.backgroundImage = "";
el.textContent = user.email.charAt(0).toUpperCase();
}
}
/** Populates the nav's user chip and admin link; redirects to /login when signed out. */
export async function loadNavUser() {
const response = await fetch("/api/auth/me");
@@ -123,7 +137,7 @@ export async function loadNavUser() {
const emailEl = document.getElementById("account-email");
if (emailEl) emailEl.textContent = user.email;
const avatarEl = document.getElementById("nav-user-avatar");
if (avatarEl) avatarEl.textContent = user.email.charAt(0).toUpperCase();
if (avatarEl) applyAvatar(avatarEl, user);
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
return user;
+288
View File
@@ -0,0 +1,288 @@
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();
+51 -2
View File
@@ -8,6 +8,7 @@ let roasts = [];
let openId = null;
let pollTimer = null;
let machineProfile = null;
let roasterList = [];
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -42,7 +43,13 @@ function renderTable() {
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent = `${roast.roast?.roastDate || fmtDate(roast.createdAt)} · ${roast.filename}`;
sub.textContent = [
roast.roast?.roastDate || fmtDate(roast.createdAt),
roast.roasterName,
roast.filename,
]
.filter(Boolean)
.join(" · ");
roastCell.append(strong, sub);
const planCell = document.createElement("td");
@@ -305,7 +312,8 @@ async function openDetail(id, { keepScroll = false } = {}) {
card.classList.remove("hidden");
document.getElementById("detail-title").textContent =
detail.roast?.title || detail.filename;
document.getElementById("detail-meta").textContent = [
const meta = document.getElementById("detail-meta");
meta.textContent = [
detail.roast?.roastDate || fmtDate(detail.createdAt),
detail.roast?.roasterType,
detail.planTitle ? `Plan: ${detail.planTitle}` : "No plan attached",
@@ -313,6 +321,42 @@ async function openDetail(id, { keepScroll = false } = {}) {
]
.filter(Boolean)
.join(" · ");
// 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`;
renderGraph(detail);
@@ -406,6 +450,11 @@ async function init() {
} catch {
machineProfile = null;
}
try {
roasterList = (await api("/api/roasters")).roasters;
} catch {
roasterList = [];
}
await loadRoasts();
const requested = new URLSearchParams(location.search).get("roast");
if (requested && roasts.some((roast) => roast.id === requested))
+150
View File
@@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Machines — 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 id="nav-links"></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>Machines</h1>
<p class="brand-sub">
Your roasters — what the app has learned from each, and your
tweaks to it
</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="roasters-card">
<div class="panel-head"><h2>Your roasters</h2></div>
<div class="panel-body">
<div class="table-wrap">
<table class="data-table" id="roasters-table">
<thead>
<tr>
<th>Roaster</th>
<th>Default</th>
<th class="num">Logged roasts</th>
<th></th>
</tr>
</thead>
<tbody id="roasters-body"></tbody>
</table>
</div>
<form
id="roaster-form"
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px"
>
<input
class="text-input"
name="name"
placeholder="Name (e.g. Hottop)"
required
style="max-width:200px"
/>
<input
class="text-input"
name="model"
placeholder="Model (e.g. KN-8828B-2K+)"
style="max-width:220px"
/>
<button class="ghost-btn" type="submit">Add roaster</button>
</form>
<p class="field-note">
Uploaded .alogs attach to the default roaster (you can reassign
any roast from its detail view on the Roasts page). Learning,
the planner's suggested curve, and roast reviews all use the
selected machine's own history.
</p>
</div>
</section>
<section class="panel-card hidden" id="report-card">
<div class="panel-head">
<h2 id="report-title">Analysis</h2>
<span class="muted" id="report-count"></span>
</div>
<div class="panel-body">
<p class="field-note" id="report-empty" hidden>
No roasts logged against this machine yet — upload finished
.alogs on the Roasts page and the analysis fills in.
</p>
<div id="report-body">
<div class="table-wrap">
<table class="data-table" id="report-table">
<thead>
<tr>
<th>What the plans use</th>
<th>Learned from your roasts</th>
<th>Your tweak</th>
<th>Applied</th>
</tr>
</thead>
<tbody id="report-rows"></tbody>
</table>
</div>
<p class="field-note" id="report-ror"></p>
<div id="report-adjustments" style="margin-top:8px"></div>
<button class="primary-btn" type="button" id="save-overrides" style="margin-top:10px">
Save tweaks
</button>
<button class="ghost-btn" type="button" id="clear-overrides" style="margin-top:10px">
Clear all tweaks (use learned values)
</button>
</div>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/roasters.js?v=__ASSET_VERSION__"></script>
</body>
</html>