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();
|
||||
+321
-39
@@ -1,47 +1,329 @@
|
||||
const csrf = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((value) => value.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
const users = document.querySelector("#users");
|
||||
const plans = document.querySelector("#plans");
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
async function load() {
|
||||
const usersResponse = await fetch("/api/admin/users");
|
||||
if (!usersResponse.ok) return;
|
||||
const body = await usersResponse.json();
|
||||
document.querySelector("#signup").checked = body.signupEnabled;
|
||||
users.replaceChildren(
|
||||
...body.users.map((user) =>
|
||||
Object.assign(document.createElement("li"), {
|
||||
textContent: `${user.email} (${user.role}) — ${user.plan_count} plans`,
|
||||
let currentUsers = [];
|
||||
let planFilterUserId = null;
|
||||
let currentUserId = null;
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
async function loadMetrics() {
|
||||
const grid = document.getElementById("metrics");
|
||||
try {
|
||||
const { metrics } = await api("/api/admin/metrics");
|
||||
const tiles = [
|
||||
["Users", metrics.totalUsers],
|
||||
["Plans", metrics.totalPlans],
|
||||
["Plans updated (7d)", metrics.plansUpdatedLast7Days],
|
||||
["Active sessions", metrics.activeSessions],
|
||||
];
|
||||
grid.replaceChildren(
|
||||
...tiles.map(([label, value]) => {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stat-tile";
|
||||
tile.innerHTML = `<div class="stat-tile-label"></div><div class="stat-tile-value"></div>`;
|
||||
tile.querySelector(".stat-tile-label").textContent = label;
|
||||
tile.querySelector(".stat-tile-value").textContent = value;
|
||||
return tile;
|
||||
}),
|
||||
),
|
||||
);
|
||||
const plansResponse = await fetch("/api/admin/plans");
|
||||
if (!plansResponse.ok) return;
|
||||
const plansBody = await plansResponse.json();
|
||||
plans.replaceChildren(
|
||||
...plansBody.plans.map((plan) =>
|
||||
Object.assign(document.createElement("li"), {
|
||||
textContent: `${plan.email}: ${plan.plan?.fields?.["0.1"] || "Untitled plan"}`,
|
||||
);
|
||||
} catch {
|
||||
grid.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResetLinks() {
|
||||
try {
|
||||
const { links } = await api("/api/admin/password-resets");
|
||||
const card = document.getElementById("reset-links-card");
|
||||
if (!links.length) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.hidden = false;
|
||||
document.getElementById("resets-body").replaceChildren(
|
||||
...links.map((link) => {
|
||||
const tr = document.createElement("tr");
|
||||
const email = document.createElement("td");
|
||||
email.textContent = link.email;
|
||||
const url = document.createElement("td");
|
||||
// Not a clickable <a>: an admin's own session immediately 302s /reset to /app,
|
||||
// so the link is only useful copied out and handed to the actual user.
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "ghost-btn small";
|
||||
copyBtn.type = "button";
|
||||
copyBtn.textContent = "Copy link";
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.url);
|
||||
copyBtn.textContent = "Copied!";
|
||||
} catch {
|
||||
copyBtn.textContent = link.url;
|
||||
}
|
||||
setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
|
||||
});
|
||||
url.append(copyBtn);
|
||||
const expires = document.createElement("td");
|
||||
expires.textContent = fmtDate(link.expiresAt);
|
||||
tr.append(email, url, expires);
|
||||
return tr;
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* optional feature; ignore failures */
|
||||
}
|
||||
}
|
||||
|
||||
function wireSignupToggle() {
|
||||
const toggle = document.getElementById("signup-toggle");
|
||||
toggle.addEventListener("change", async () => {
|
||||
try {
|
||||
await api("/api/admin/signup-enabled", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: toggle.checked }),
|
||||
});
|
||||
showToast(toggle.checked ? "Signups enabled." : "Signups disabled.");
|
||||
} catch (error) {
|
||||
toggle.checked = !toggle.checked;
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function roleBadge(user) {
|
||||
return user.role === "admin"
|
||||
? `<span class="badge badge-admin">Admin</span>`
|
||||
: `<span class="badge badge-user">User</span>`;
|
||||
}
|
||||
function statusBadge(user) {
|
||||
return user.disabled_at
|
||||
? `<span class="badge badge-disabled">Disabled</span>`
|
||||
: `<span class="badge badge-current">Active</span>`;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const body = document.getElementById("users-body");
|
||||
try {
|
||||
const { users, signupEnabled } = await api("/api/admin/users");
|
||||
currentUsers = users;
|
||||
document.getElementById("signup-toggle").checked = signupEnabled;
|
||||
renderUsers();
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load users.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const body = document.getElementById("users-body");
|
||||
const query = document
|
||||
.getElementById("user-search")
|
||||
.value.trim()
|
||||
.toLowerCase();
|
||||
const filtered = query
|
||||
? currentUsers.filter((u) => u.email.toLowerCase().includes(query))
|
||||
: currentUsers;
|
||||
if (!filtered.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No matching users.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...filtered.map((user) => {
|
||||
const tr = document.createElement("tr");
|
||||
const email = document.createElement("td");
|
||||
email.textContent = user.email;
|
||||
const role = document.createElement("td");
|
||||
role.innerHTML = roleBadge(user);
|
||||
const status = document.createElement("td");
|
||||
status.innerHTML = statusBadge(user);
|
||||
const count = document.createElement("td");
|
||||
count.className = "num";
|
||||
count.textContent = user.plan_count;
|
||||
const joined = document.createElement("td");
|
||||
joined.textContent = fmtDate(user.created_at);
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
|
||||
const isBootstrapAdmin = user.email === "[email protected]";
|
||||
const isSelf = user.id === currentUserId;
|
||||
const viewPlans = document.createElement("button");
|
||||
viewPlans.className = "ghost-btn small";
|
||||
viewPlans.type = "button";
|
||||
viewPlans.textContent = "View plans";
|
||||
viewPlans.addEventListener("click", () => filterPlansByUser(user));
|
||||
actions.append(viewPlans);
|
||||
|
||||
// The server refuses role/disable/delete on the bootstrap admin and on your own
|
||||
// account (so an admin can never lock themselves out) — don't offer buttons the
|
||||
// server will always reject.
|
||||
if (!isBootstrapAdmin && !isSelf) {
|
||||
const roleBtn = document.createElement("button");
|
||||
roleBtn.className = "ghost-btn small";
|
||||
roleBtn.type = "button";
|
||||
const promoting = user.role !== "admin";
|
||||
roleBtn.textContent = promoting ? "Promote" : "Demote";
|
||||
roleBtn.addEventListener("click", async () => {
|
||||
if (
|
||||
promoting &&
|
||||
!confirm(
|
||||
`Grant ${user.email} full admin access, including user management and password-reset links?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/role`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
role: promoting ? "admin" : "user",
|
||||
}),
|
||||
});
|
||||
showToast("Role updated.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
const disableBtn = document.createElement("button");
|
||||
disableBtn.className = "ghost-btn small";
|
||||
disableBtn.type = "button";
|
||||
const disabling = !user.disabled_at;
|
||||
disableBtn.textContent = disabling ? "Disable" : "Enable";
|
||||
disableBtn.addEventListener("click", async () => {
|
||||
if (
|
||||
disabling &&
|
||||
!confirm(`Disable ${user.email}? This signs them out everywhere immediately.`)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/disabled`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ disabled: disabling }),
|
||||
});
|
||||
showToast(disabling ? "User disabled." : "User enabled.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
if (
|
||||
!confirm(
|
||||
`Permanently delete ${user.email} and all of their plans?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
||||
showToast("User deleted.");
|
||||
loadUsers();
|
||||
loadMetrics();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(roleBtn, disableBtn, deleteBtn);
|
||||
}
|
||||
|
||||
tr.append(email, role, status, count, joined, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
document.querySelector("#save").addEventListener("click", async () => {
|
||||
await fetch("/api/admin/signup-enabled", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": csrf(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: document.querySelector("#signup").checked,
|
||||
}),
|
||||
});
|
||||
await load();
|
||||
async function loadPlans() {
|
||||
const body = document.getElementById("plans-body");
|
||||
try {
|
||||
const url = planFilterUserId
|
||||
? `/api/admin/plans?user=${encodeURIComponent(planFilterUserId)}`
|
||||
: "/api/admin/plans";
|
||||
const { plans } = await api(url);
|
||||
if (!plans.length) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="empty-state">No plans.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...plans.map((plan) => {
|
||||
const tr = document.createElement("tr");
|
||||
const owner = document.createElement("td");
|
||||
owner.textContent = plan.email;
|
||||
const title = document.createElement("td");
|
||||
title.textContent = plan.title || "Untitled plan";
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(plan.updated_at);
|
||||
tr.append(owner, title, updated);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterPlansByUser(user) {
|
||||
planFilterUserId = user.id;
|
||||
document.getElementById("clear-plan-filter").classList.remove("hidden");
|
||||
document.getElementById("plans").scrollIntoView({ behavior: "smooth" });
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const body = document.getElementById("audit-body");
|
||||
try {
|
||||
const { events } = await api("/api/admin/audit");
|
||||
if (!events.length) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">No activity yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...events.map((event) => {
|
||||
const tr = document.createElement("tr");
|
||||
const when = document.createElement("td");
|
||||
when.textContent = fmtDate(event.created_at);
|
||||
const actor = document.createElement("td");
|
||||
actor.textContent = event.actor_email || "—";
|
||||
const action = document.createElement("td");
|
||||
action.textContent = event.action.replaceAll("_", " ");
|
||||
const target = document.createElement("td");
|
||||
target.textContent = event.target || "—";
|
||||
tr.append(when, actor, action, target);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load activity.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
});
|
||||
document.getElementById("user-search").addEventListener("input", renderUsers);
|
||||
document.getElementById("clear-plan-filter").addEventListener("click", () => {
|
||||
planFilterUserId = null;
|
||||
document.getElementById("clear-plan-filter").classList.add("hidden");
|
||||
loadPlans();
|
||||
});
|
||||
|
||||
load();
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
currentUserId = user.id;
|
||||
wireSignupToggle();
|
||||
await Promise.all([
|
||||
loadMetrics(),
|
||||
loadResetLinks(),
|
||||
loadUsers(),
|
||||
loadPlans(),
|
||||
loadAudit(),
|
||||
]);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const csrfToken = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
|
||||
export const protectedFetch = (url, options = {}) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
headers: { ...options.headers, "x-csrf-token": csrfToken() },
|
||||
});
|
||||
|
||||
export async function api(url, options = {}) {
|
||||
const response = await protectedFetch(url, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...options.headers },
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && body.code === "unauthorized") {
|
||||
// This is specifically requireAuth's code for "no valid session" — the session expired
|
||||
// or was revoked mid-page (account/admin pages otherwise show a permanent "could not
|
||||
// load" error with no indication the user was signed out). A 401 with any other code
|
||||
// (e.g. a wrong current password on an account form) is a normal request failure, not a
|
||||
// sign-out, and must not redirect the user away mid-form.
|
||||
location.assign("/login");
|
||||
return new Promise(() => {}); // navigation is already underway; never resolve
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(body.error || body.code || "request_failed");
|
||||
error.code = body.code;
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { wireWhyPanels } from "./why-panels.js";
|
||||
import {
|
||||
SCORE_ATTRS,
|
||||
SCORE_LABELS,
|
||||
TICK_ATTRS,
|
||||
TICK_LABELS,
|
||||
FLAVOR_TAXONOMY,
|
||||
MAX_FLAVOR_TAGS,
|
||||
MAX_CUP_COUNT,
|
||||
computeTotalScore,
|
||||
} from "/shared/cupping.js";
|
||||
|
||||
const sessionId = new URLSearchParams(location.search).get("session");
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
/** Disables `el` for the duration of `run()` so a slow request can't be double-submitted. */
|
||||
async function guarded(el, run) {
|
||||
if (!el || el.disabled) return;
|
||||
el.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
el.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List view ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadSessions() {
|
||||
const body = document.getElementById("sessions-body");
|
||||
try {
|
||||
const { sessions } = await api("/api/cupping");
|
||||
if (!sessions.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No cupping sessions yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...sessions.map((s) => {
|
||||
const tr = document.createElement("tr");
|
||||
const coffee = document.createElement("td");
|
||||
coffee.textContent = s.planTitle || "Untitled";
|
||||
const score = document.createElement("td");
|
||||
score.className = "num";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge badge-current";
|
||||
badge.textContent = s.totalScore.toFixed(2);
|
||||
score.append(badge);
|
||||
const cups = document.createElement("td");
|
||||
cups.className = "num";
|
||||
cups.textContent = s.cupCount;
|
||||
const flavors = document.createElement("td");
|
||||
const shown = s.flavorTags.slice(0, 3).map((t) => t.split(".").pop());
|
||||
flavors.textContent =
|
||||
shown.join(", ") + (s.flavorTags.length > 3 ? ` +${s.flavorTags.length - 3}` : "");
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(s.updatedAt);
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const open = document.createElement("a");
|
||||
open.className = "ghost-btn small";
|
||||
open.href = `/cupping?session=${s.id}`;
|
||||
open.textContent = "Open";
|
||||
const del = document.createElement("button");
|
||||
del.className = "ghost-btn small";
|
||||
del.type = "button";
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", (event) => {
|
||||
if (!confirm("Delete this cupping session?")) return;
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api(`/api/cupping/${s.id}`, { method: "DELETE" });
|
||||
showToast("Session deleted.");
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
actions.append(open, del);
|
||||
tr.append(coffee, score, cups, flavors, updated, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load sessions.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlanOptions() {
|
||||
const select = document.getElementById("new-session-plan");
|
||||
try {
|
||||
const { plans } = await api("/api/plans");
|
||||
select.append(
|
||||
...plans.map((p) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = p.id;
|
||||
option.textContent = p.plan?.fields?.["0.1"] || "Untitled plan";
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* the plan-less option still works */
|
||||
}
|
||||
}
|
||||
|
||||
function wireNewSessionForm() {
|
||||
const form = document.getElementById("new-session-form");
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const roastPlanId = document.getElementById("new-session-plan").value || undefined;
|
||||
const cupCount = Number(document.getElementById("new-session-cups").value) || 5;
|
||||
guarded(form.querySelector("button[type=submit]"), async () => {
|
||||
try {
|
||||
const { session } = await api("/api/cupping", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ roastPlanId, cupCount }),
|
||||
});
|
||||
location.assign(`/cupping?session=${session.id}`);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Session view ────────────────────────────────────────────────────────
|
||||
|
||||
let data = null; // the coerced session document (snake_case, matches shared/cupping.js)
|
||||
let saveTimer = null;
|
||||
|
||||
function setAutosaveStatus(status) {
|
||||
const chip = document.getElementById("cupping-autosave-status");
|
||||
const text = chip.querySelector(".autosave-text");
|
||||
chip.classList.remove("saving", "saved", "failed");
|
||||
chip.classList.add(status);
|
||||
text.textContent =
|
||||
{ saving: "Saving…", saved: "Synced", failed: "Sync failed" }[status] || "Not saved yet";
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
setAutosaveStatus("saving");
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(save, 500);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const body = await api(`/api/cupping/${sessionId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
document.getElementById("cup-total").textContent = body.session.totalScore.toFixed(2);
|
||||
setAutosaveStatus("saved");
|
||||
} catch (error) {
|
||||
setAutosaveStatus("failed");
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
function liveTotal() {
|
||||
return computeTotalScore(
|
||||
data.scores,
|
||||
data.ticks,
|
||||
data.taint_cups,
|
||||
data.fault_cups,
|
||||
data.cup_count,
|
||||
);
|
||||
}
|
||||
|
||||
function renderTotal() {
|
||||
document.getElementById("cup-total").textContent = liveTotal().toFixed(2);
|
||||
}
|
||||
|
||||
// ── Score sliders ──
|
||||
function renderScoreRows() {
|
||||
const wrap = document.getElementById("cup-score-rows");
|
||||
wrap.replaceChildren(
|
||||
...SCORE_ATTRS.map((attr) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-score-row";
|
||||
row.dataset.attr = attr;
|
||||
const unscored = !(data.scores[attr] > 0);
|
||||
row.classList.toggle("unscored", unscored);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "cup-score-label";
|
||||
label.textContent = SCORE_LABELS[attr];
|
||||
|
||||
const slider = document.createElement("input");
|
||||
slider.type = "range";
|
||||
slider.min = "6";
|
||||
slider.max = "10";
|
||||
slider.step = "0.25";
|
||||
slider.value = unscored ? "6" : data.scores[attr];
|
||||
slider.setAttribute("aria-label", `${SCORE_LABELS[attr]} score`);
|
||||
|
||||
const output = document.createElement("output");
|
||||
output.className = "cup-score-value";
|
||||
output.textContent = unscored ? "—" : data.scores[attr].toFixed(2);
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.type = "button";
|
||||
clearBtn.className = "ghost-btn small cup-score-clear";
|
||||
clearBtn.hidden = unscored;
|
||||
clearBtn.textContent = "✕";
|
||||
clearBtn.setAttribute("aria-label", `Clear ${SCORE_LABELS[attr]} score`);
|
||||
|
||||
slider.addEventListener("input", () => {
|
||||
data.scores[attr] = Number(slider.value);
|
||||
row.classList.remove("unscored");
|
||||
output.textContent = data.scores[attr].toFixed(2);
|
||||
clearBtn.hidden = false;
|
||||
renderTotal();
|
||||
renderRadar();
|
||||
scheduleSave();
|
||||
});
|
||||
clearBtn.addEventListener("click", () => {
|
||||
data.scores[attr] = 0;
|
||||
row.classList.add("unscored");
|
||||
slider.value = "6";
|
||||
output.textContent = "—";
|
||||
clearBtn.hidden = true;
|
||||
renderTotal();
|
||||
renderRadar();
|
||||
scheduleSave();
|
||||
});
|
||||
|
||||
row.append(label, slider, output, clearBtn);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick attributes (fill-up-to cup cells) ──
|
||||
function renderTickRows() {
|
||||
const wrap = document.getElementById("cup-tick-rows");
|
||||
wrap.replaceChildren(
|
||||
...TICK_ATTRS.map((attr) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-tick-row";
|
||||
row.dataset.tick = attr;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "cup-score-label";
|
||||
label.textContent = TICK_LABELS[attr];
|
||||
|
||||
const cells = document.createElement("div");
|
||||
cells.className = "cup-tick-cells";
|
||||
cells.setAttribute("role", "group");
|
||||
cells.setAttribute("aria-label", `${TICK_LABELS[attr]} — cups passed`);
|
||||
|
||||
const output = document.createElement("output");
|
||||
|
||||
function renderCells() {
|
||||
const count = data.ticks[attr] || 0;
|
||||
cells.replaceChildren(
|
||||
...Array.from({ length: data.cup_count }, (_, i) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "cup-tick";
|
||||
btn.setAttribute("aria-label", `Cup ${i + 1}`);
|
||||
const pressed = i < count;
|
||||
btn.setAttribute("aria-pressed", String(pressed));
|
||||
btn.addEventListener("click", () => {
|
||||
data.ticks[attr] = i >= (data.ticks[attr] || 0) ? i + 1 : i;
|
||||
renderCells();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
return btn;
|
||||
}),
|
||||
);
|
||||
output.textContent = `${count}/${data.cup_count}`;
|
||||
}
|
||||
renderCells();
|
||||
row._renderCells = renderCells;
|
||||
|
||||
row.append(label, cells, output);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Defects (taint/fault steppers) ──
|
||||
function renderDefectRows() {
|
||||
const wrap = document.getElementById("cup-defect-rows");
|
||||
const specs = [
|
||||
{ key: "taint_cups", label: "Taint cups", note: "−2 pts each" },
|
||||
{ key: "fault_cups", label: "Fault cups", note: "−4 pts each" },
|
||||
];
|
||||
wrap.replaceChildren(
|
||||
...specs.map(({ key, label, note }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-stepper-row";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "cup-score-label";
|
||||
labelEl.textContent = `${label} (${note})`;
|
||||
const minus = document.createElement("button");
|
||||
minus.type = "button";
|
||||
minus.className = "ghost-btn small";
|
||||
minus.textContent = "−";
|
||||
minus.setAttribute("aria-label", `Decrease ${label}`);
|
||||
const count = document.createElement("output");
|
||||
const plus = document.createElement("button");
|
||||
plus.type = "button";
|
||||
plus.className = "ghost-btn small";
|
||||
plus.textContent = "+";
|
||||
plus.setAttribute("aria-label", `Increase ${label}`);
|
||||
|
||||
function update() {
|
||||
count.textContent = data[key];
|
||||
}
|
||||
minus.addEventListener("click", () => {
|
||||
data[key] = Math.max(0, data[key] - 1);
|
||||
update();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
plus.addEventListener("click", () => {
|
||||
data[key] = Math.min(data.cup_count, data[key] + 1);
|
||||
update();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
update();
|
||||
row.append(labelEl, minus, count, plus);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function reclampForCupCount() {
|
||||
for (const attr of TICK_ATTRS) data.ticks[attr] = Math.min(data.ticks[attr] || 0, data.cup_count);
|
||||
data.taint_cups = Math.min(data.taint_cups, data.cup_count);
|
||||
data.fault_cups = Math.min(data.fault_cups, data.cup_count);
|
||||
}
|
||||
|
||||
function wireCupCount() {
|
||||
const input = document.getElementById("cup-count");
|
||||
input.min = "1";
|
||||
input.max = String(MAX_CUP_COUNT);
|
||||
input.value = data.cup_count;
|
||||
input.addEventListener("change", () => {
|
||||
const next = Math.max(1, Math.min(MAX_CUP_COUNT, Number(input.value) || 1));
|
||||
data.cup_count = next;
|
||||
input.value = next;
|
||||
reclampForCupCount();
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Flavor checklist ──
|
||||
function renderFlavorChips() {
|
||||
const chips = document.getElementById("flavor-chips");
|
||||
chips.replaceChildren(
|
||||
...data.flavor_tags.map((tag) => {
|
||||
// A dedicated class, not .chip-opt (a checkbox-option style whose CSS uses a
|
||||
// descendant `span` selector — reusing it here with a nested span for the remove
|
||||
// button doubled-up borders/padding onto that inner span too).
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "flavor-chip";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = tag.split(".").pop().replaceAll("_", " ");
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "flavor-chip-remove";
|
||||
remove.setAttribute("aria-label", `Remove ${text.textContent}`);
|
||||
remove.textContent = "✕";
|
||||
remove.addEventListener("click", () => toggleFlavorTag(tag, false));
|
||||
chip.append(text, remove);
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
document
|
||||
.getElementById("flavor-limit-note")
|
||||
.classList.toggle("hidden", data.flavor_tags.length < MAX_FLAVOR_TAGS);
|
||||
}
|
||||
|
||||
function toggleFlavorTag(tag, checked) {
|
||||
if (checked) {
|
||||
if (data.flavor_tags.length >= MAX_FLAVOR_TAGS || data.flavor_tags.includes(tag)) return;
|
||||
data.flavor_tags.push(tag);
|
||||
} else {
|
||||
data.flavor_tags = data.flavor_tags.filter((t) => t !== tag);
|
||||
}
|
||||
renderFlavorFamilies();
|
||||
renderFlavorChips();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function renderFlavorFamilies() {
|
||||
const wrap = document.getElementById("flavor-families");
|
||||
const atLimit = data.flavor_tags.length >= MAX_FLAVOR_TAGS;
|
||||
wrap.replaceChildren(
|
||||
...Object.entries(FLAVOR_TAXONOMY).map(([familyId, family]) => {
|
||||
const selectedCount = data.flavor_tags.filter((t) => t.startsWith(`${familyId}.`)).length;
|
||||
const details = document.createElement("details");
|
||||
details.className = "flavor-family";
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = family.label + (selectedCount ? ` (${selectedCount})` : "");
|
||||
details.append(summary);
|
||||
for (const [subId, descriptors] of Object.entries(family.subgroups)) {
|
||||
const subhead = document.createElement("p");
|
||||
subhead.className = "subhead";
|
||||
subhead.textContent = subId.replaceAll("_", " ");
|
||||
const group = document.createElement("div");
|
||||
group.className = "chip-group";
|
||||
for (const descriptor of descriptors) {
|
||||
const tag = `${familyId}.${subId}.${descriptor}`;
|
||||
const label = document.createElement("label");
|
||||
label.className = "chip-opt";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = data.flavor_tags.includes(tag);
|
||||
input.disabled = atLimit && !input.checked;
|
||||
input.addEventListener("change", () => toggleFlavorTag(tag, input.checked));
|
||||
const span = document.createElement("span");
|
||||
span.textContent = descriptor.replaceAll("_", " ");
|
||||
label.append(input, span);
|
||||
group.append(label);
|
||||
}
|
||||
details.append(subhead, group);
|
||||
}
|
||||
return details;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radar chart (pure SVG, no dependency) ──
|
||||
const RADAR_CENTER = 120;
|
||||
const RADAR_RADIUS = 90;
|
||||
|
||||
function radarPoints() {
|
||||
const n = SCORE_ATTRS.length;
|
||||
return SCORE_ATTRS.map((attr, i) => {
|
||||
const raw = data.scores[attr] || 0;
|
||||
const frac = raw > 0 ? Math.max(0, Math.min(1, (raw - 6) / 4)) : 0;
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
|
||||
return {
|
||||
x: RADAR_CENTER + frac * RADAR_RADIUS * Math.cos(angle),
|
||||
y: RADAR_CENTER + frac * RADAR_RADIUS * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function initRadarStatic() {
|
||||
const svg = document.getElementById("cup-radar");
|
||||
svg.replaceChildren();
|
||||
// Rings at scores 7/8/9/10 (6 is the center — an unscored/floor axis point).
|
||||
for (const score of [7, 8, 9, 10]) {
|
||||
const r = ((score - 6) / 4) * RADAR_RADIUS;
|
||||
svg.append(
|
||||
svgEl("circle", {
|
||||
cx: RADAR_CENTER,
|
||||
cy: RADAR_CENTER,
|
||||
r,
|
||||
fill: "none",
|
||||
stroke: "var(--line)",
|
||||
"stroke-width": "1",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = SCORE_ATTRS.length;
|
||||
SCORE_ATTRS.forEach((attr, i) => {
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
|
||||
const x2 = RADAR_CENTER + RADAR_RADIUS * Math.cos(angle);
|
||||
const y2 = RADAR_CENTER + RADAR_RADIUS * Math.sin(angle);
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
x1: RADAR_CENTER,
|
||||
y1: RADAR_CENTER,
|
||||
x2,
|
||||
y2,
|
||||
stroke: "var(--line)",
|
||||
"stroke-width": "1",
|
||||
}),
|
||||
);
|
||||
const lx = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.cos(angle);
|
||||
const ly = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.sin(angle);
|
||||
const label = svgEl("text", {
|
||||
x: lx,
|
||||
y: ly,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "middle",
|
||||
"font-size": "9",
|
||||
fill: "var(--ink-2)",
|
||||
});
|
||||
label.textContent = SCORE_LABELS[attr].split("/")[0];
|
||||
svg.append(label);
|
||||
});
|
||||
const shape = svgEl("polygon", {
|
||||
id: "radar-shape",
|
||||
fill: "var(--ember)",
|
||||
"fill-opacity": "0.25",
|
||||
stroke: "var(--ember)",
|
||||
"stroke-width": "1.5",
|
||||
});
|
||||
svg.append(shape);
|
||||
}
|
||||
|
||||
function renderRadar() {
|
||||
const shape = document.getElementById("radar-shape");
|
||||
if (!shape) return;
|
||||
shape.setAttribute("points", radarPoints().map((p) => `${p.x},${p.y}`).join(" "));
|
||||
}
|
||||
|
||||
function wireNotes() {
|
||||
const textarea = document.getElementById("cup-notes-text");
|
||||
textarea.value = data.notes;
|
||||
document.getElementById("cup-notes-count").textContent = data.notes.length;
|
||||
textarea.addEventListener("input", () => {
|
||||
data.notes = textarea.value;
|
||||
document.getElementById("cup-notes-count").textContent = data.notes.length;
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
async function initSessionView(user) {
|
||||
document.getElementById("cupping-list-view").classList.add("hidden");
|
||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||
document.getElementById("session-autosave-wrap").classList.remove("hidden");
|
||||
setAutosaveStatus("saved");
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await api(`/api/cupping/${sessionId}`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not load this session.", "fail");
|
||||
location.assign("/cupping");
|
||||
return;
|
||||
}
|
||||
data = body.session.data;
|
||||
document.getElementById("cupping-title").textContent =
|
||||
body.session.planTitle || "Cupping session";
|
||||
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
|
||||
|
||||
wireCupCount();
|
||||
renderScoreRows();
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderFlavorFamilies();
|
||||
renderFlavorChips();
|
||||
wireNotes();
|
||||
initRadarStatic();
|
||||
renderRadar();
|
||||
renderTotal();
|
||||
wireWhyPanels();
|
||||
void user;
|
||||
}
|
||||
|
||||
async function initListView() {
|
||||
document.getElementById("cupping-list-view").classList.remove("hidden");
|
||||
document.getElementById("cupping-session-view").classList.add("hidden");
|
||||
wireNewSessionForm();
|
||||
await Promise.all([loadSessions(), loadPlanOptions()]);
|
||||
}
|
||||
|
||||
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;
|
||||
if (sessionId) await initSessionView(user);
|
||||
else await initListView();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,30 @@
|
||||
const form = document.querySelector("#forgot-form");
|
||||
const message = document.querySelector("#message");
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/auth/forgot", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
if (response.status === 429) {
|
||||
message.textContent = "Too many requests. Try again in a few minutes.";
|
||||
message.className = "auth-message error";
|
||||
return;
|
||||
}
|
||||
// Always show the same message, whether or not the account exists.
|
||||
message.textContent =
|
||||
"If that email has an account, a reset link is on its way.";
|
||||
message.className = "auth-message info";
|
||||
form.reset();
|
||||
} catch {
|
||||
message.textContent = "Could not reach the server. Try again shortly.";
|
||||
message.className = "auth-message error";
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
let lots = [];
|
||||
let showArchived = false;
|
||||
let editingId = null;
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleDateString() : "—";
|
||||
}
|
||||
|
||||
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
|
||||
async function guarded(button, run) {
|
||||
if (!button || button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLots() {
|
||||
const body = document.getElementById("lots-body");
|
||||
const visible = showArchived ? lots : lots.filter((l) => !l.archived);
|
||||
if (!visible.length) {
|
||||
body.innerHTML = `<tr><td colspan="5" class="empty-state">No lots yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...visible.map((lot) => {
|
||||
const tr = document.createElement("tr");
|
||||
if (lot.archived) tr.className = "archived";
|
||||
|
||||
const lotCell = document.createElement("td");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = lot.origin;
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "muted";
|
||||
sub.style.fontSize = "11.5px";
|
||||
sub.textContent = [lot.variety, lot.producer].filter(Boolean).join(" · ") || "—";
|
||||
lotCell.append(strong, sub);
|
||||
|
||||
const processCell = document.createElement("td");
|
||||
processCell.textContent = lot.process || "—";
|
||||
|
||||
const remainingCell = document.createElement("td");
|
||||
const pct = lot.initialWeightG > 0
|
||||
? Math.max(0, Math.min(100, (lot.remainingWeightG / lot.initialWeightG) * 100))
|
||||
: 0;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "lot-remaining";
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "blend-total-bar";
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "blend-total-fill";
|
||||
if (lot.remainingWeightG < 0) fill.classList.add("over");
|
||||
fill.style.width = `${lot.remainingWeightG < 0 ? 0 : pct}%`;
|
||||
bar.append(fill);
|
||||
const label = document.createElement("span");
|
||||
label.className = "blend-total-label";
|
||||
label.textContent = `${Math.round(lot.remainingWeightG)} g of ${Math.round(lot.initialWeightG)} g`;
|
||||
wrap.append(bar, label);
|
||||
remainingCell.append(wrap);
|
||||
|
||||
const purchasedCell = document.createElement("td");
|
||||
purchasedCell.textContent = fmtDate(lot.purchaseDate);
|
||||
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "ghost-btn small";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", () => startEdit(lot));
|
||||
|
||||
const archiveBtn = document.createElement("button");
|
||||
archiveBtn.className = "ghost-btn small";
|
||||
archiveBtn.type = "button";
|
||||
archiveBtn.textContent = lot.archived ? "Unarchive" : "Archive";
|
||||
archiveBtn.addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api(`/api/inventory/${lot.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ archived: !lot.archived }),
|
||||
});
|
||||
showToast(lot.archived ? "Lot unarchived." : "Lot archived.");
|
||||
await loadLots();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const logBtn = document.createElement("button");
|
||||
logBtn.className = "ghost-btn small";
|
||||
logBtn.type = "button";
|
||||
logBtn.textContent = "Log";
|
||||
logBtn.addEventListener("click", () => toggleLog(lot, tr, logBtn));
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
if (!confirm(`Delete the ${lot.origin} lot? This cannot be undone.`))
|
||||
return;
|
||||
try {
|
||||
await api(`/api/inventory/${lot.id}`, { method: "DELETE" });
|
||||
showToast("Lot deleted.");
|
||||
if (editingId === lot.id) cancelEdit();
|
||||
await loadLots();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
actions.append(editBtn, archiveBtn, logBtn, deleteBtn);
|
||||
tr.append(lotCell, processCell, remainingCell, purchasedCell, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleLog(lot, row, button) {
|
||||
const existing = row.nextElementSibling;
|
||||
if (existing?.classList.contains("lot-log-row")) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
guarded(button, async () => {
|
||||
try {
|
||||
const { log } = await api(`/api/inventory/${lot.id}`);
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "lot-log-row";
|
||||
const td = document.createElement("td");
|
||||
td.colSpan = 5;
|
||||
if (!log.length) {
|
||||
td.className = "empty-state";
|
||||
td.textContent = "No consumption recorded yet.";
|
||||
} else {
|
||||
const ul = document.createElement("ul");
|
||||
ul.style.margin = "0";
|
||||
ul.style.paddingLeft = "18px";
|
||||
for (const entry of log) {
|
||||
const li = document.createElement("li");
|
||||
li.style.fontSize = "12.5px";
|
||||
li.textContent = `${Math.round(entry.weightG)} g — ${entry.planTitle || "manual"} — ${fmtDate(entry.createdAt)}`;
|
||||
ul.append(li);
|
||||
}
|
||||
td.append(ul);
|
||||
}
|
||||
tr.append(td);
|
||||
row.after(tr);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLots() {
|
||||
try {
|
||||
const body = await api("/api/inventory");
|
||||
lots = body.lots;
|
||||
renderLots();
|
||||
} catch (error) {
|
||||
document.getElementById("lots-body").innerHTML =
|
||||
`<tr><td colspan="5" class="empty-state">Could not load lots.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(lot) {
|
||||
editingId = lot.id;
|
||||
const form = document.getElementById("lot-form");
|
||||
form.id.value = lot.id;
|
||||
form.origin.value = lot.origin;
|
||||
form.variety.value = lot.variety;
|
||||
form.process.value = lot.process;
|
||||
form.producer.value = lot.producer;
|
||||
form.purchaseDate.value = lot.purchaseDate ? lot.purchaseDate.slice(0, 10) : "";
|
||||
form.initialWeightG.value = lot.initialWeightG;
|
||||
form.costTotal.value = lot.costTotal ?? "";
|
||||
form.moisturePct.value = lot.moisturePct ?? "";
|
||||
form.densityGL.value = lot.densityGL ?? "";
|
||||
form.notes.value = lot.notes;
|
||||
document.getElementById("lot-form-title").textContent = "Edit lot";
|
||||
document.getElementById("lot-form-submit").textContent = "Save changes";
|
||||
document.getElementById("lot-form-cancel").classList.remove("hidden");
|
||||
const note = document.getElementById("lot-remaining-note");
|
||||
note.classList.remove("hidden");
|
||||
note.textContent = `Remaining: ${Math.round(lot.remainingWeightG)} g — remaining weight is only changed by roasts drawing from the lot. Correct the initial weight and the remaining shifts with it.`;
|
||||
document.getElementById("lot-form-card").scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
const form = document.getElementById("lot-form");
|
||||
form.reset();
|
||||
form.id.value = "";
|
||||
document.getElementById("lot-form-title").textContent = "Add a lot";
|
||||
document.getElementById("lot-form-submit").textContent = "Add lot";
|
||||
document.getElementById("lot-form-cancel").classList.add("hidden");
|
||||
document.getElementById("lot-remaining-note").classList.add("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("lot-form-cancel").addEventListener("click", cancelEdit);
|
||||
document.getElementById("show-archived").addEventListener("change", (event) => {
|
||||
showArchived = event.target.checked;
|
||||
renderLots();
|
||||
});
|
||||
document.getElementById("lot-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const submitBtn = document.getElementById("lot-form-submit");
|
||||
guarded(submitBtn, async () => {
|
||||
try {
|
||||
const payload = {
|
||||
origin: data.origin,
|
||||
variety: data.variety,
|
||||
process: data.process,
|
||||
producer: data.producer,
|
||||
purchaseDate: data.purchaseDate || null,
|
||||
initialWeightG: Number(data.initialWeightG),
|
||||
costTotal: data.costTotal === "" ? null : Number(data.costTotal),
|
||||
moisturePct: data.moisturePct === "" ? null : Number(data.moisturePct),
|
||||
densityGL: data.densityGL === "" ? null : Number(data.densityGL),
|
||||
notes: data.notes,
|
||||
};
|
||||
if (editingId) {
|
||||
await api(`/api/inventory/${editingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
showToast("Lot updated.");
|
||||
} else {
|
||||
await api("/api/inventory", { method: "POST", body: JSON.stringify(payload) });
|
||||
showToast("Lot added.");
|
||||
}
|
||||
cancelEdit();
|
||||
await loadLots();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 loadLots();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -1,25 +0,0 @@
|
||||
const form = document.querySelector("#auth-form");
|
||||
const message = document.querySelector("#message");
|
||||
|
||||
async function submit(url) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
message.textContent = body.error || body.code;
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
submit("/api/auth/login");
|
||||
});
|
||||
document.querySelector("#signup").addEventListener("click", () => {
|
||||
const setupToken = form.elements.setupToken.value;
|
||||
submit(setupToken ? "/api/auth/bootstrap" : "/api/auth/signup");
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
const form = document.querySelector("#login-form");
|
||||
const message = document.querySelector("#message");
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
body.code === "too_many_attempts"
|
||||
? "Too many attempts. Try again in a few minutes."
|
||||
: body.error || "Could not log in. Check your email and password.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
let lots = [];
|
||||
|
||||
function findLot(id) {
|
||||
return lots.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
/** Wires the "From inventory lot" picker (in The Coffee) and the "Draw from lot" action
|
||||
* (in Roast Log — Plan vs. Actual). `getRemotePlanId` is a function since the planner's
|
||||
* remotePlanId is module-local and can change after a sync. */
|
||||
export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId }) {
|
||||
const select = document.getElementById("lot-select");
|
||||
const note = document.getElementById("lot-picker-note");
|
||||
const consumeRow = document.getElementById("inventory-consume");
|
||||
const consumeLabel = document.getElementById("inventory-consume-label");
|
||||
const consumeBtn = document.getElementById("btn-consume");
|
||||
|
||||
function renderOptions() {
|
||||
const current = state.plan.inventory.lotId;
|
||||
select.replaceChildren(
|
||||
Object.assign(document.createElement("option"), { value: "", textContent: "— none —" }),
|
||||
...lots
|
||||
.filter((l) => !l.archived)
|
||||
.map((l) =>
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: l.id,
|
||||
textContent: `${l.origin}${l.variety ? ` — ${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (current && !findLot(current)) {
|
||||
// The plan references a lot that's now archived/deleted — keep it selectable so the
|
||||
// stored value still displays instead of silently reverting to "— none —".
|
||||
select.append(
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: current,
|
||||
textContent: state.plan.inventory.lotLabel || "Unavailable lot",
|
||||
disabled: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
select.value = current || "";
|
||||
}
|
||||
|
||||
async function loadLots() {
|
||||
try {
|
||||
const body = await api("/api/inventory");
|
||||
lots = body.lots;
|
||||
} catch {
|
||||
lots = [];
|
||||
}
|
||||
renderOptions();
|
||||
updateConsumeRow();
|
||||
}
|
||||
|
||||
function updateConsumeRow() {
|
||||
const lotId = state.plan.inventory.lotId;
|
||||
if (!lotId) {
|
||||
consumeRow.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
consumeRow.classList.remove("hidden");
|
||||
const consumed = state.plan.inventory.consumed;
|
||||
if (consumed) {
|
||||
consumeLabel.textContent = `✓ ${Math.round(consumed.weightG)} g drawn from ${consumed.lotLabel} · ${new Date(consumed.atIso).toLocaleDateString()}`;
|
||||
consumeBtn.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
consumeBtn.classList.remove("hidden");
|
||||
const weightG = Number(state.plan.fields["0.4"]);
|
||||
const lot = findLot(lotId);
|
||||
const lotLabel = lot ? lot.origin : state.plan.inventory.lotLabel || "this lot";
|
||||
if (Number.isFinite(weightG) && weightG > 0) {
|
||||
consumeLabel.textContent = `Charging will draw ${weightG} g from ${lotLabel}.`;
|
||||
consumeBtn.disabled = false;
|
||||
consumeBtn.removeAttribute("title");
|
||||
} else {
|
||||
consumeLabel.textContent = `Set "Green in" (field 0.4) to draw weight from ${lotLabel}.`;
|
||||
consumeBtn.disabled = true;
|
||||
consumeBtn.title = "Enter a Green in weight on The Coffee first";
|
||||
}
|
||||
}
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
const lotId = select.value;
|
||||
state.plan.inventory.lotId = lotId;
|
||||
const lot = findLot(lotId);
|
||||
state.plan.inventory.lotLabel = lot
|
||||
? `${lot.origin}${lot.variety ? ` — ${lot.variety}` : ""}`
|
||||
: "";
|
||||
if (lot) {
|
||||
note.textContent = `${Math.round(lot.remainingWeightG)} g remaining in this lot.`;
|
||||
const nameInput = document.querySelector('[name="0.1"]');
|
||||
if (nameInput && !nameInput.value) {
|
||||
nameInput.value = lot.origin;
|
||||
state.plan.fields["0.1"] = lot.origin;
|
||||
}
|
||||
} else {
|
||||
note.textContent =
|
||||
"Optional — link a lot and the Roast Log can draw the green weight down when you charge.";
|
||||
}
|
||||
updateConsumeRow();
|
||||
recompute();
|
||||
});
|
||||
|
||||
consumeBtn.addEventListener("click", async () => {
|
||||
consumeBtn.disabled = true;
|
||||
try {
|
||||
if (!(await flushCurrentPlan())) {
|
||||
showToast("Could not sync the plan. Try again.", "fail");
|
||||
return;
|
||||
}
|
||||
const roastPlanId = getRemotePlanId();
|
||||
if (!roastPlanId) {
|
||||
showToast("Sync the plan first, then draw from the lot.", "fail");
|
||||
return;
|
||||
}
|
||||
const weightG = Number(state.plan.fields["0.4"]);
|
||||
const response = await protectedFetch(
|
||||
`/api/inventory/${state.plan.inventory.lotId}/consume`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ weightG, roastPlanId }),
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok && body.code !== "already_consumed") {
|
||||
showToast(body.error || "Could not draw from the lot.", "fail");
|
||||
return;
|
||||
}
|
||||
state.plan.inventory.consumed = {
|
||||
lotId: state.plan.inventory.lotId,
|
||||
lotLabel: state.plan.inventory.lotLabel,
|
||||
weightG,
|
||||
atIso: new Date().toISOString(),
|
||||
};
|
||||
updateConsumeRow();
|
||||
recompute();
|
||||
showToast(`${weightG} g drawn from lot.`);
|
||||
await loadLots();
|
||||
} finally {
|
||||
consumeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadLots();
|
||||
return { updateConsumeRow, renderOptions };
|
||||
}
|
||||
+157
-121
@@ -10,117 +10,26 @@ import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
|
||||
import { initPrefillPanel } from "./prefill-ui.js";
|
||||
import { initAlogPanel } from "./alog-ui.js";
|
||||
import { initPrint } from "./print.js";
|
||||
import { initLotPicker } from "./lot-picker.js";
|
||||
import { protectedFetch, csrfToken } from "./api.js";
|
||||
import { initSideNav } from "./nav.js";
|
||||
import { wireWhyPanels } from "./why-panels.js";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
||||
let storageKey = null;
|
||||
let remotePlanId = null;
|
||||
let draftSyncedAt = null;
|
||||
let plans = [];
|
||||
let lastDrawerOpener = null;
|
||||
let deferredInstallPrompt = null;
|
||||
const csrfToken = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
export const protectedFetch = (url, options = {}) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
headers: { ...options.headers, "x-csrf-token": csrfToken() },
|
||||
});
|
||||
let lotPicker = null;
|
||||
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
||||
|
||||
export const state = { plan: blankPlan() };
|
||||
|
||||
const form = document.getElementById("plan-form");
|
||||
|
||||
const FIELD_HELP = {
|
||||
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
|
||||
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
|
||||
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
|
||||
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
|
||||
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
|
||||
};
|
||||
function helpText(input) {
|
||||
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
|
||||
const unit = input
|
||||
.closest(".unit-input")
|
||||
?.querySelector(".unit")?.textContent;
|
||||
const label =
|
||||
input
|
||||
.closest(".field")
|
||||
?.querySelector(".field-label")
|
||||
?.textContent?.trim() ||
|
||||
input.getAttribute("aria-label") ||
|
||||
input.placeholder ||
|
||||
"This value";
|
||||
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
|
||||
}
|
||||
function wireFieldHelp(root = document) {
|
||||
for (const input of root.querySelectorAll(
|
||||
"input[name], select[name], textarea[name], #prefill-url",
|
||||
)) {
|
||||
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
|
||||
const field = input.closest(".field");
|
||||
if (
|
||||
input.dataset.helpWired ||
|
||||
(input.type === "radio" && field?.dataset.radioHelp === input.name)
|
||||
)
|
||||
continue;
|
||||
input.dataset.helpWired = "true";
|
||||
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
|
||||
const id = `field-help-${Math.random().toString(36).slice(2)}`;
|
||||
const help = document.createElement("span");
|
||||
help.id = id;
|
||||
help.className = "field-help-text";
|
||||
help.hidden = true;
|
||||
help.textContent = helpText(input);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "field-help";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-controls", id);
|
||||
button.setAttribute("aria-label", `Learn about ${input.name}`);
|
||||
button.textContent = "?";
|
||||
button.addEventListener("click", () => {
|
||||
const open = help.hidden;
|
||||
help.hidden = !open;
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
const milestone = input
|
||||
.closest(".milestone-row")
|
||||
?.querySelector(".milestone-label")
|
||||
?.textContent?.trim();
|
||||
const fieldLabel = field
|
||||
?.querySelector(".field-label")
|
||||
?.textContent?.trim();
|
||||
input.setAttribute(
|
||||
"aria-label",
|
||||
input.getAttribute("aria-label") ||
|
||||
milestone ||
|
||||
fieldLabel ||
|
||||
input.placeholder ||
|
||||
"Plan value",
|
||||
);
|
||||
input.setAttribute(
|
||||
"aria-describedby",
|
||||
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
|
||||
);
|
||||
const host = field || input.closest(".unit-input") || input;
|
||||
let wrapper = host?.parentElement?.classList.contains("field-help-host")
|
||||
? host.parentElement
|
||||
: null;
|
||||
if (!wrapper && host) {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "field-help-host";
|
||||
host.replaceWith(wrapper);
|
||||
wrapper.append(host);
|
||||
}
|
||||
// Labels cannot contain another interactive control, so the help button is a sibling.
|
||||
wrapper?.append(button, help);
|
||||
}
|
||||
}
|
||||
|
||||
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
|
||||
// so its radios don't fight the screen form's identically-named radios for exclusivity.
|
||||
// Every sync pass therefore has to reach both containers explicitly.
|
||||
@@ -235,7 +144,6 @@ function updateBlendVisibility(mode) {
|
||||
function renderBlend() {
|
||||
renderBlendPrintRows();
|
||||
renderBlendCards();
|
||||
wireFieldHelp(document.getElementById("blend-cards"));
|
||||
updateBlendTotal();
|
||||
}
|
||||
|
||||
@@ -293,7 +201,6 @@ function renderActuatorTimeline() {
|
||||
function renderActuators() {
|
||||
renderActuatorPrintRows();
|
||||
renderActuatorTimeline();
|
||||
wireFieldHelp(document.getElementById("actuator-timeline"));
|
||||
}
|
||||
|
||||
// ---- populate every control in the form from state.plan
|
||||
@@ -493,10 +400,41 @@ function renderCurve(ledger) {
|
||||
);
|
||||
}
|
||||
|
||||
// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
|
||||
// is "0:00", since charge is always t=0) — compare against those defaults, not against "",
|
||||
// so a fresh plan doesn't read as already having roast-day data.
|
||||
const BLANK_PLAN = blankPlan();
|
||||
|
||||
// True once any value in `obj` differs from the same path in `blank` — used to lift the
|
||||
// "phase-later" muting off Roast Log / After the Roast the moment the user has actually started
|
||||
// using them, without ever hiding the plan column they exist to compare against.
|
||||
function hasVal(obj, blank) {
|
||||
if (obj == null) return false;
|
||||
return Object.keys(obj).some((key) => {
|
||||
const v = obj[key];
|
||||
return typeof v === "object" && v !== null
|
||||
? hasVal(v, blank?.[key])
|
||||
: v !== "" && v != null && v !== blank?.[key];
|
||||
});
|
||||
}
|
||||
|
||||
export function recompute() {
|
||||
const ledger = renderLedger();
|
||||
renderCurve(ledger);
|
||||
renderBandMarkers();
|
||||
document
|
||||
.getElementById("sec-roastlog")
|
||||
?.classList.toggle(
|
||||
"has-data",
|
||||
hasVal(state.plan.planActual, BLANK_PLAN.planActual),
|
||||
);
|
||||
document
|
||||
.getElementById("sec-after")
|
||||
?.classList.toggle(
|
||||
"has-data",
|
||||
hasVal(state.plan.afterRoast, BLANK_PLAN.afterRoast),
|
||||
);
|
||||
lotPicker?.updateConsumeRow();
|
||||
autosave();
|
||||
}
|
||||
|
||||
@@ -514,10 +452,16 @@ function setSaveStatus(status) {
|
||||
}[status] || "Not saved yet";
|
||||
}
|
||||
let syncQueue = Promise.resolve();
|
||||
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly
|
||||
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local
|
||||
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and
|
||||
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that
|
||||
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session)
|
||||
// must check remotePlanId themselves afterward, which they already do.
|
||||
function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||
if (!navigator.onLine || !csrfToken()) {
|
||||
setSaveStatus("local");
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
syncQueue = syncQueue.then(async () => {
|
||||
try {
|
||||
@@ -532,10 +476,11 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||
if (!response.ok) throw new Error("sync_failed");
|
||||
const body = await response.json();
|
||||
remotePlanId = body.plan.id;
|
||||
draftSyncedAt = body.plan.updated_at;
|
||||
if (storageKey)
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
|
||||
);
|
||||
history.replaceState(
|
||||
null,
|
||||
@@ -544,8 +489,10 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||
);
|
||||
setSaveStatus("saved");
|
||||
await loadPlans();
|
||||
return true;
|
||||
} catch {
|
||||
setSaveStatus("failed");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return syncQueue;
|
||||
@@ -557,14 +504,13 @@ async function flushCurrentPlan() {
|
||||
if (storageKey)
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
|
||||
);
|
||||
} catch {
|
||||
setSaveStatus("failed");
|
||||
return false;
|
||||
}
|
||||
await syncPlan(snapshot);
|
||||
return true;
|
||||
return await syncPlan(snapshot);
|
||||
}
|
||||
function autosave() {
|
||||
setSaveStatus("saving");
|
||||
@@ -594,6 +540,7 @@ function loadFromStorage(userId) {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft?.plan) {
|
||||
remotePlanId = draft.remotePlanId || null;
|
||||
draftSyncedAt = draft.syncedAt || null;
|
||||
return draft.plan;
|
||||
}
|
||||
return draft; // legacy v2 draft: retain it once, then upgrade on next save
|
||||
@@ -611,6 +558,7 @@ function clearDraft() {
|
||||
}
|
||||
storageKey = null;
|
||||
remotePlanId = null;
|
||||
draftSyncedAt = null;
|
||||
}
|
||||
|
||||
function cultivarAutofill(name) {
|
||||
@@ -670,16 +618,18 @@ function wireDrawers() {
|
||||
panel.querySelector("button, input, [href]")?.focus();
|
||||
}
|
||||
for (const [buttonId, panelId] of [
|
||||
["btn-toggle-prefill", "panel-prefill"],
|
||||
["btn-toggle-alog", "panel-alog"],
|
||||
["btn-plans", "panel-plans"],
|
||||
["btn-settings", "panel-settings"],
|
||||
["nav-prefill", "panel-prefill"],
|
||||
["nav-alog", "panel-alog"],
|
||||
["nav-plans", "panel-plans"],
|
||||
["nav-settings", "panel-settings"],
|
||||
["nav-import-export", "panel-import-export"],
|
||||
])
|
||||
document
|
||||
.getElementById(buttonId)
|
||||
.addEventListener("click", (event) =>
|
||||
open(document.getElementById(panelId), event.currentTarget),
|
||||
);
|
||||
.addEventListener("click", (event) => {
|
||||
document.getElementById("side-nav")?.classList.remove("mobile-open");
|
||||
open(document.getElementById(panelId), event.currentTarget);
|
||||
});
|
||||
overlay.addEventListener("click", closeAll);
|
||||
for (const btn of document.querySelectorAll("[data-close-drawer]"))
|
||||
btn.addEventListener("click", closeAll);
|
||||
@@ -738,6 +688,7 @@ async function loadPlans() {
|
||||
async function selectPlan(plan) {
|
||||
if (!(await flushCurrentPlan())) return;
|
||||
remotePlanId = plan.id;
|
||||
draftSyncedAt = plan.updated_at;
|
||||
state.plan = { ...blankPlan(), ...plan.plan };
|
||||
history.replaceState(
|
||||
null,
|
||||
@@ -748,6 +699,11 @@ async function selectPlan(plan) {
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
// renderFormFromPlan() sets the lot <select>'s value, but that only sticks if the option is
|
||||
// already in the DOM — re-render the picker's own option list against the newly-loaded
|
||||
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
|
||||
// leave the dropdown showing the previous plan's selection or a blank state.
|
||||
lotPicker?.renderOptions();
|
||||
document.querySelector("[data-close-drawer]")?.click();
|
||||
}
|
||||
async function newPlan() {
|
||||
@@ -755,12 +711,14 @@ async function newPlan() {
|
||||
return;
|
||||
if (!(await flushCurrentPlan())) return;
|
||||
remotePlanId = null;
|
||||
draftSyncedAt = null;
|
||||
state.plan = blankPlan();
|
||||
history.replaceState(null, "", "/app");
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
lotPicker?.renderOptions();
|
||||
}
|
||||
|
||||
function wireToolbar() {
|
||||
@@ -864,6 +822,10 @@ function wirePwa() {
|
||||
renderConnection();
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
// A brand-new visitor has no controller yet, so the *first* activation firing
|
||||
// "controllerchange" is not an update — only a page that was already controlled by a
|
||||
// previous service worker should reload when a new one takes over.
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
@@ -882,9 +844,9 @@ function wirePwa() {
|
||||
console.warn("Service worker registration failed:", error),
|
||||
);
|
||||
});
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () =>
|
||||
location.reload(),
|
||||
);
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (hadController) location.reload();
|
||||
});
|
||||
}
|
||||
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||
if (!(await flushCurrentPlan())) return;
|
||||
@@ -927,21 +889,36 @@ async function init() {
|
||||
const { user } = await meResponse.json();
|
||||
document.getElementById("account-email").textContent = user.email;
|
||||
document.getElementById("settings-email").textContent = user.email;
|
||||
document.getElementById("account-summary").textContent =
|
||||
user.email.split("@")[0];
|
||||
document.getElementById("nav-user-avatar").textContent = user.email
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
if (user.role === "admin")
|
||||
document.getElementById("menu-admin").classList.remove("hidden");
|
||||
document.getElementById("nav-admin").classList.remove("hidden");
|
||||
const localDraft = loadFromStorage(user.id);
|
||||
state.plan = localDraft ?? blankPlan();
|
||||
const draftRemoteId = remotePlanId;
|
||||
const draftSyncedAtAtLoad = draftSyncedAt;
|
||||
await loadPlans();
|
||||
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||
const selected = plans.find((plan) => plan.id === requestedId);
|
||||
if (selected) {
|
||||
state.plan = selected.plan;
|
||||
// Prefer the local draft only when it targets this exact plan AND was last confirmed
|
||||
// synced at or after the server's own updated_at — otherwise another device's newer
|
||||
// edit (or a draft from before sync tracking existed) would be silently discarded.
|
||||
const draftIsCurrent =
|
||||
selected &&
|
||||
draftRemoteId === selected.id &&
|
||||
draftSyncedAtAtLoad &&
|
||||
new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
|
||||
if (selected && !draftIsCurrent) {
|
||||
state.plan = { ...blankPlan(), ...selected.plan };
|
||||
remotePlanId = selected.id;
|
||||
draftSyncedAt = selected.updated_at;
|
||||
} else if (selected) {
|
||||
remotePlanId = selected.id;
|
||||
} else if (!localDraft && !requestedId && plans[0]) {
|
||||
state.plan = plans[0].plan;
|
||||
state.plan = { ...blankPlan(), ...plans[0].plan };
|
||||
remotePlanId = plans[0].id;
|
||||
draftSyncedAt = plans[0].updated_at;
|
||||
}
|
||||
} catch {
|
||||
// The cached app shell contains no user data. A successful online sign-in records the
|
||||
@@ -957,10 +934,11 @@ async function init() {
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
wireCultivarDatalist();
|
||||
wireFieldHelp();
|
||||
wireWhyPanels();
|
||||
renderBandRanges();
|
||||
renderFormFromPlan();
|
||||
wireForm();
|
||||
initSideNav();
|
||||
wireDrawers();
|
||||
wireToolbar();
|
||||
wirePwa();
|
||||
@@ -974,7 +952,65 @@ async function init() {
|
||||
});
|
||||
initAlogPanel({ state, recompute });
|
||||
initPrint({ beforePrint: renderFormFromPlan });
|
||||
lotPicker = initLotPicker({
|
||||
state,
|
||||
recompute,
|
||||
flushCurrentPlan,
|
||||
getRemotePlanId: () => remotePlanId,
|
||||
});
|
||||
wireCuppingLink();
|
||||
recompute();
|
||||
}
|
||||
|
||||
function wireCuppingLink() {
|
||||
const button = document.getElementById("btn-open-cupping");
|
||||
const note = document.getElementById("cupping-link-note");
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (!(await flushCurrentPlan())) {
|
||||
note.textContent = "Could not sync the plan. Try again.";
|
||||
return;
|
||||
}
|
||||
if (!remotePlanId) {
|
||||
note.textContent = "Sync the plan first, then open a cupping session.";
|
||||
return;
|
||||
}
|
||||
const existing = await protectedFetch(
|
||||
`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`,
|
||||
)
|
||||
.then((r) => (r.ok ? r.json() : { sessions: [] }))
|
||||
.catch(() => ({ sessions: [] }));
|
||||
if (existing.sessions?.length) {
|
||||
location.assign(`/cupping?session=${existing.sessions[0].id}`);
|
||||
return;
|
||||
}
|
||||
let created;
|
||||
try {
|
||||
created = await protectedFetch("/api/cupping", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ roastPlanId: remotePlanId }),
|
||||
}).then((r) => r.json());
|
||||
} catch {
|
||||
note.textContent = "Could not reach the server. Try again.";
|
||||
return;
|
||||
}
|
||||
if (created.session) location.assign(`/cupping?session=${created.session.id}`);
|
||||
else note.textContent = "Could not start a cupping session.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
// Non-blocking: show whether a session already exists without forcing a sync first.
|
||||
if (remotePlanId)
|
||||
protectedFetch(`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
if (body?.sessions?.length)
|
||||
note.textContent = `Session exists — ${body.sessions[0].totalScore.toFixed(2)}`;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
|
||||
// (planner, account, admin) so each page's own script doesn't reimplement it.
|
||||
export function initSideNav() {
|
||||
const nav = document.getElementById("side-nav");
|
||||
const hamburger = document.getElementById("nav-hamburger");
|
||||
const collapseBtn = document.getElementById("nav-collapse");
|
||||
const workspace = document.getElementById("app-workspace");
|
||||
const overlay = document.getElementById("drawer-overlay");
|
||||
const COLLAPSE_KEY = "roastPlannerNavCollapsed";
|
||||
|
||||
function closeMobileNav() {
|
||||
nav.classList.remove("mobile-open");
|
||||
hamburger?.setAttribute("aria-expanded", "false");
|
||||
if (!document.querySelector(".drawer:not(.hidden)"))
|
||||
overlay?.classList.add("hidden");
|
||||
}
|
||||
hamburger?.addEventListener("click", () => {
|
||||
const open = nav.classList.toggle("mobile-open");
|
||||
hamburger.setAttribute("aria-expanded", String(open));
|
||||
overlay?.classList.toggle("hidden", !open);
|
||||
});
|
||||
overlay?.addEventListener("click", closeMobileNav);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeMobileNav();
|
||||
});
|
||||
|
||||
function applyCollapsed(collapsed) {
|
||||
nav.classList.toggle("collapsed", collapsed);
|
||||
workspace?.classList.toggle("nav-collapsed", collapsed);
|
||||
if (collapseBtn) {
|
||||
collapseBtn.textContent = collapsed ? "»" : "«";
|
||||
collapseBtn.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? "Expand navigation" : "Collapse navigation",
|
||||
);
|
||||
}
|
||||
}
|
||||
applyCollapsed(localStorage.getItem(COLLAPSE_KEY) === "true");
|
||||
collapseBtn?.addEventListener("click", () => {
|
||||
const next = !nav.classList.contains("collapsed");
|
||||
applyCollapsed(next);
|
||||
localStorage.setItem(COLLAPSE_KEY, String(next));
|
||||
});
|
||||
return { closeMobileNav };
|
||||
}
|
||||
|
||||
/** 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");
|
||||
if (!response.ok) {
|
||||
location.replace("/login");
|
||||
return null;
|
||||
}
|
||||
const { user } = await response.json();
|
||||
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 (user.role === "admin")
|
||||
document.getElementById("nav-admin")?.classList.remove("hidden");
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const form = document.querySelector("#reset-form");
|
||||
const message = document.querySelector("#message");
|
||||
const resetToken = new URLSearchParams(location.search).get("token") || "";
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
|
||||
if (!resetToken) {
|
||||
form.classList.add("hidden");
|
||||
showError(
|
||||
"This reset link is missing its token. Request a new one from the forgot-password page.",
|
||||
);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (data.password !== data.confirmPassword) {
|
||||
showError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/reset", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token: resetToken, password: data.password }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
{
|
||||
invalid_token:
|
||||
"This reset link is invalid or has expired. Request a new one.",
|
||||
account_disabled:
|
||||
"Your password was updated, but this account is disabled. Contact an administrator.",
|
||||
}[body.code] || body.error || "Could not reset the password.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const form = document.querySelector("#setup-form");
|
||||
const message = document.querySelector("#message");
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/bootstrap", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
{
|
||||
bootstrap_used: "The administrator account already exists.",
|
||||
invalid_setup_token: "That setup token is incorrect.",
|
||||
bootstrap_unavailable: "Setup is not available in this deployment.",
|
||||
}[body.code] || body.error || "Could not create the administrator.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
const form = document.querySelector("#signup-form");
|
||||
const message = document.querySelector("#message");
|
||||
const title = document.querySelector("#signup-title");
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
function showInfo(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message info";
|
||||
}
|
||||
|
||||
(async function checkSignupEnabled() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/signup-enabled");
|
||||
const body = await response.json();
|
||||
if (body.enabled) {
|
||||
form.classList.remove("hidden");
|
||||
} else {
|
||||
title.textContent = "Invite only";
|
||||
showInfo(
|
||||
"New signups are currently disabled. Ask an administrator for an invitation.",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Don't strand a new customer on a dead page over one failed probe — show the form and
|
||||
// let the real submit enforce the signup-enabled rule if it turns out to matter.
|
||||
form.classList.remove("hidden");
|
||||
showError(
|
||||
"Could not confirm signups are open. You can still try creating an account below.",
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (data.password !== data.confirmPassword) {
|
||||
showError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: data.email, password: data.password }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
{
|
||||
email_exists: "An account with that email already exists.",
|
||||
signup_disabled:
|
||||
"New signups are currently disabled. Ask an administrator for an invitation.",
|
||||
}[body.code] || body.error || "Could not create the account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
function stack() {
|
||||
let el = document.querySelector(".toast-stack");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.className = "toast-stack";
|
||||
el.setAttribute("aria-live", "polite");
|
||||
document.body.append(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function showToast(text, type = "") {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast ${type}`.trim();
|
||||
toast.textContent = text;
|
||||
stack().append(toast);
|
||||
setTimeout(() => toast.remove(), 4500);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Shared teaching-layer collapse memory for every page that uses `.why-panel` (`<details>`
|
||||
// with a `data-why` id): the planner and the cupping session view. One localStorage key across
|
||||
// both so a user's dismissals carry over between pages.
|
||||
const WHY_COLLAPSED_KEY = "roastPlannerWhyCollapsed.v1";
|
||||
|
||||
export function wireWhyPanels() {
|
||||
let collapsed;
|
||||
try {
|
||||
collapsed = new Set(JSON.parse(localStorage.getItem(WHY_COLLAPSED_KEY)) || []);
|
||||
} catch {
|
||||
collapsed = new Set();
|
||||
}
|
||||
for (const panel of document.querySelectorAll(".why-panel")) {
|
||||
if (collapsed.has(panel.dataset.why)) panel.open = false;
|
||||
panel.addEventListener("toggle", () => {
|
||||
if (panel.open) collapsed.delete(panel.dataset.why);
|
||||
else collapsed.add(panel.dataset.why);
|
||||
try {
|
||||
localStorage.setItem(WHY_COLLAPSED_KEY, JSON.stringify([...collapsed]));
|
||||
} catch {
|
||||
/* unavailable storage */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user