Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from hope_roaster: lot tracking with audit-logged consumption, cupping sessions with server-authoritative scoring and a live radar chart, and links from the planner (draw-from-lot, open-cupping-session). Also fixes the Blend/Single-origin toggle layout, replaces tooltips with an in-context "why" teaching layer, and stages the planner UI into pre-roast vs. post-roast phases.
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
await Promise.all([loadSessions(), loadPlans()]);
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user