Files
roast_command_center/public/js/account.js
T
Shane MaynardandClaude Fable 5 a341d67467
Test and deploy / test-and-deploy (push) Successful in 1m0s
Add roaster (machine) management, mobile header-tools redesign, and profile pictures
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]>
2026-08-09 08:17:10 -04:00

477 lines
15 KiB
JavaScript

import { api, protectedFetch } from "./api.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) {
return value ? new Date(value).toLocaleString() : "—";
}
function friendlyDevice(userAgent) {
if (!userAgent) return "Unknown device";
const browser = /Edg\//.test(userAgent)
? "Edge"
: /OPR\//.test(userAgent)
? "Opera"
: /Chrome\//.test(userAgent)
? "Chrome"
: /CriOS\//.test(userAgent)
? "Chrome"
: /Firefox\//.test(userAgent)
? "Firefox"
: /Safari\//.test(userAgent)
? "Safari"
: "Browser";
const os = /Windows/.test(userAgent)
? "Windows"
: /iPhone|iPad/.test(userAgent)
? "iOS"
: /Mac OS X/.test(userAgent)
? "macOS"
: /Android/.test(userAgent)
? "Android"
: /Linux/.test(userAgent)
? "Linux"
: "";
return os ? `${browser} on ${os}` : browser;
}
async function loadProfile(user) {
document.getElementById("profile-email").textContent = user.email;
document
.getElementById("profile-role-admin")
.classList.toggle("hidden", user.role !== "admin");
document.getElementById("email-form").email.value = user.email;
}
async function loadSessions() {
const body = document.getElementById("sessions-body");
try {
const { sessions } = await api("/api/account/sessions");
if (!sessions.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No active sessions.</td></tr>`;
return;
}
body.replaceChildren(
...sessions.map((session) => {
const tr = document.createElement("tr");
const device = document.createElement("td");
const label = document.createElement("span");
label.className = "session-device-label";
label.textContent = friendlyDevice(session.userAgent);
if (session.userAgent) label.title = session.userAgent;
device.append(label);
if (session.current) {
const badge = document.createElement("span");
badge.className = "badge badge-current";
badge.textContent = "This device";
badge.style.marginLeft = "8px";
device.append(badge);
}
const lastSeen = document.createElement("td");
lastSeen.textContent = fmtDate(session.lastSeenAt || session.createdAt);
const expires = document.createElement("td");
expires.textContent = fmtDate(session.expiresAt);
const actions = document.createElement("td");
const revoke = document.createElement("button");
revoke.className = "ghost-btn small";
revoke.type = "button";
revoke.textContent = session.current ? "Sign out" : "Revoke";
revoke.addEventListener("click", async () => {
try {
await api(`/api/account/sessions/${session.id}`, {
method: "DELETE",
});
if (session.current) return location.assign("/login");
showToast("Session revoked.");
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(revoke);
tr.append(device, lastSeen, expires, actions);
return tr;
}),
);
} catch (error) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load sessions.</td></tr>`;
}
}
async function loadPlans() {
const body = document.getElementById("plans-body");
try {
const response = await fetch("/api/plans");
if (!response.ok) throw new Error("could not load plans");
const { plans } = await response.json();
if (!plans.length) {
body.innerHTML = `<tr><td colspan="3" class="empty-state">No saved plans yet.</td></tr>`;
return;
}
body.replaceChildren(
...plans.map((plan) => {
const tr = document.createElement("tr");
const title = document.createElement("td");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("td");
updated.textContent = fmtDate(plan.updated_at);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const open = document.createElement("a");
open.className = "ghost-btn small";
open.href = `/app?plan=${encodeURIComponent(plan.id)}`;
open.textContent = "Open";
const duplicate = document.createElement("button");
duplicate.className = "ghost-btn small";
duplicate.type = "button";
duplicate.textContent = "Duplicate";
duplicate.addEventListener("click", async () => {
try {
await api("/api/plans", {
method: "POST",
body: JSON.stringify({ plan: plan.plan }),
});
showToast("Plan duplicated.");
loadPlans();
} catch (error) {
showToast(error.message, "fail");
}
});
const download = document.createElement("button");
download.className = "ghost-btn small";
download.type = "button";
download.textContent = "Download";
download.addEventListener("click", () => {
const blob = new Blob([JSON.stringify(plan.plan, null, 2)], {
type: "application/json",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
const cup = document.createElement("button");
cup.className = "ghost-btn small";
cup.type = "button";
cup.textContent = "Cup";
cup.addEventListener("click", async () => {
cup.disabled = true;
try {
const existing = await api(`/api/cupping?plan=${encodeURIComponent(plan.id)}`);
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
return;
}
const created = await api("/api/cupping", {
method: "POST",
body: JSON.stringify({ roastPlanId: plan.id }),
});
location.assign(`/cupping?session=${created.session.id}`);
} catch (error) {
showToast(error.message, "fail");
cup.disabled = false;
}
});
const del = document.createElement("button");
del.className = "ghost-btn small";
del.type = "button";
del.textContent = "Delete";
del.addEventListener("click", async () => {
if (!confirm("Delete this plan? This cannot be undone.")) return;
try {
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
showToast("Plan deleted.");
loadPlans();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(open, duplicate, download, cup, del);
tr.append(title, updated, actions);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
}
}
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
async function guarded(button, run) {
if (button.disabled) return;
button.disabled = true;
try {
await run();
} finally {
button.disabled = false;
}
}
function wireForms(user) {
document.getElementById("email-form").addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
try {
await api("/api/account/email", { method: "PUT", body: JSON.stringify(data) });
showToast("Email updated.");
document.getElementById("profile-email").textContent = data.email;
event.target.password.value = "";
} catch (error) {
showToast(
error.code === "email_exists"
? "That email is already in use."
: error.message,
"fail",
);
}
});
});
document
.getElementById("password-form")
.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
if (data.newPassword !== data.confirmPassword) {
showToast("New passwords do not match.", "fail");
return;
}
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
try {
await api("/api/account/password", {
method: "PUT",
body: JSON.stringify(data),
});
showToast("Password updated. Other sessions were signed out.");
event.target.reset();
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
});
document
.getElementById("btn-revoke-others")
.addEventListener("click", (event) => {
guarded(event.currentTarget, async () => {
try {
await api("/api/account/sessions/revoke-others", { method: "POST" });
showToast("Other sessions signed out.");
loadSessions();
} catch (error) {
showToast(error.message, "fail");
}
});
});
document.getElementById("delete-form").addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
if (data.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) {
showToast("Type your email exactly to confirm.", "fail");
return;
}
if (!confirm("This permanently deletes your account and plans. Continue?"))
return;
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
try {
await api("/api/account", {
method: "DELETE",
body: JSON.stringify({ password: data.password }),
});
location.assign("/");
} catch (error) {
showToast(
error.code === "last_admin"
? "You are the only administrator — promote another admin first."
: error.message,
"fail",
);
}
});
});
}
function wirePwaButtons() {
let deferredInstallPrompt = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event;
document.getElementById("btn-install").classList.remove("hidden");
});
document.getElementById("btn-install").addEventListener("click", async () => {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
await deferredInstallPrompt.userChoice;
deferredInstallPrompt = null;
document.getElementById("btn-install").classList.add("hidden");
});
if ("serviceWorker" in navigator) {
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration?.waiting)
document.getElementById("btn-refresh").classList.remove("hidden");
});
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (hadController) location.reload();
});
}
document.getElementById("btn-refresh").addEventListener("click", async () => {
const registration = await navigator.serviceWorker.getRegistration();
registration?.waiting?.postMessage("SKIP_WAITING");
});
}
// 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 {
const { tokens } = await api("/api/tokens");
if (!tokens.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No API tokens yet.</td></tr>`;
return;
}
body.replaceChildren(
...tokens.map((token) => {
const tr = document.createElement("tr");
const name = document.createElement("td");
name.textContent = token.name || "(unnamed)";
const created = document.createElement("td");
created.textContent = fmtDate(token.createdAt);
const used = document.createElement("td");
used.textContent = token.lastUsedAt ? fmtDate(token.lastUsedAt) : "Never";
const actions = document.createElement("td");
actions.className = "data-table-actions";
const revoke = document.createElement("button");
revoke.className = "ghost-btn small";
revoke.type = "button";
revoke.textContent = "Revoke";
revoke.addEventListener("click", async () => {
if (!confirm(`Revoke "${token.name || "this token"}"? Anything using it stops working immediately.`))
return;
try {
await api(`/api/tokens/${token.id}`, { method: "DELETE" });
showToast("Token revoked.");
await loadTokens();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(revoke);
tr.append(name, created, used, actions);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load tokens.</td></tr>`;
}
}
function wireTokenForm() {
document.getElementById("token-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = document.getElementById("token-create");
button.disabled = true;
try {
const created = await api("/api/tokens", {
method: "POST",
body: JSON.stringify({ name: event.target.name.value.trim() }),
});
// Shown exactly once — the server only stores a hash.
document.getElementById("token-reveal").textContent = created.token;
document.getElementById("token-reveal-wrap").classList.remove("hidden");
event.target.reset();
await loadTokens();
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
}
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;
document.getElementById("profile-since").textContent =
`Member since ${fmtDate(user.createdAt)}`;
await loadProfile(user);
wireForms(user);
wirePwaButtons();
wireTokenForm();
wireAvatar(user);
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
init();