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
+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();