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:
2026-07-30 14:47:15 -04:00
parent 9f0a3dbc74
commit 59645e59b5
43 changed files with 7582 additions and 566 deletions
+351
View File
@@ -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
View File
@@ -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();
+35
View File
@@ -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;
}
+586
View File
@@ -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();
+30
View File
@@ -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;
}
});
+265
View File
@@ -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();
-25
View File
@@ -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");
});
+35
View File
@@ -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;
}
});
+151
View File
@@ -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
View File
@@ -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();
+62
View File
@@ -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;
}
+51
View File
@@ -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;
}
});
+37
View File
@@ -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;
}
});
+69
View File
@@ -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;
}
});
+18
View File
@@ -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);
}
+25
View File
@@ -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 */
}
});
}
}