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