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