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:
+157
-121
@@ -10,117 +10,26 @@ import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
|
||||
import { initPrefillPanel } from "./prefill-ui.js";
|
||||
import { initAlogPanel } from "./alog-ui.js";
|
||||
import { initPrint } from "./print.js";
|
||||
import { initLotPicker } from "./lot-picker.js";
|
||||
import { protectedFetch, csrfToken } from "./api.js";
|
||||
import { initSideNav } from "./nav.js";
|
||||
import { wireWhyPanels } from "./why-panels.js";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
||||
let storageKey = null;
|
||||
let remotePlanId = null;
|
||||
let draftSyncedAt = null;
|
||||
let plans = [];
|
||||
let lastDrawerOpener = null;
|
||||
let deferredInstallPrompt = null;
|
||||
const csrfToken = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
export const protectedFetch = (url, options = {}) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
headers: { ...options.headers, "x-csrf-token": csrfToken() },
|
||||
});
|
||||
let lotPicker = null;
|
||||
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
||||
|
||||
export const state = { plan: blankPlan() };
|
||||
|
||||
const form = document.getElementById("plan-form");
|
||||
|
||||
const FIELD_HELP = {
|
||||
5.1: "Optional moisture percentage. Find it on a supplier certificate of analysis (COA) or measure it with a calibrated meter. Leave it blank when unknown: it does not automatically change your roast timing.",
|
||||
5.2: "Optional green-bean density in g/L. Get it from a supplier COA or measure a known volume. Leave it blank when unknown: it does not automatically change your roast timing.",
|
||||
5.6: "A documented timing adjustment in m:ss, normally no more than ±0:15. Use only after an observation or comparison roast; moisture and density never create this value automatically.",
|
||||
1.4: "Your first-crack anchor in m:ss. It is the starting point for the Time Ledger; use a cultivar reference or a previous comparable roast.",
|
||||
4.3: "Development base in m:ss. Together with the processing and cultivar modifiers it determines development and drop time.",
|
||||
};
|
||||
function helpText(input) {
|
||||
if (FIELD_HELP[input.name]) return FIELD_HELP[input.name];
|
||||
const unit = input
|
||||
.closest(".unit-input")
|
||||
?.querySelector(".unit")?.textContent;
|
||||
const label =
|
||||
input
|
||||
.closest(".field")
|
||||
?.querySelector(".field-label")
|
||||
?.textContent?.trim() ||
|
||||
input.getAttribute("aria-label") ||
|
||||
input.placeholder ||
|
||||
"This value";
|
||||
return `${label} records your plan or roast observation${unit ? ` in ${unit}` : ""}. Use the format shown; leave it blank when you do not know it, then refine it from a supplier record or your next roast.`;
|
||||
}
|
||||
function wireFieldHelp(root = document) {
|
||||
for (const input of root.querySelectorAll(
|
||||
"input[name], select[name], textarea[name], #prefill-url",
|
||||
)) {
|
||||
if (!input.closest("#plan-form") && input.id !== "prefill-url") continue;
|
||||
const field = input.closest(".field");
|
||||
if (
|
||||
input.dataset.helpWired ||
|
||||
(input.type === "radio" && field?.dataset.radioHelp === input.name)
|
||||
)
|
||||
continue;
|
||||
input.dataset.helpWired = "true";
|
||||
if (input.type === "radio" && field) field.dataset.radioHelp = input.name;
|
||||
const id = `field-help-${Math.random().toString(36).slice(2)}`;
|
||||
const help = document.createElement("span");
|
||||
help.id = id;
|
||||
help.className = "field-help-text";
|
||||
help.hidden = true;
|
||||
help.textContent = helpText(input);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "field-help";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
button.setAttribute("aria-controls", id);
|
||||
button.setAttribute("aria-label", `Learn about ${input.name}`);
|
||||
button.textContent = "?";
|
||||
button.addEventListener("click", () => {
|
||||
const open = help.hidden;
|
||||
help.hidden = !open;
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
const milestone = input
|
||||
.closest(".milestone-row")
|
||||
?.querySelector(".milestone-label")
|
||||
?.textContent?.trim();
|
||||
const fieldLabel = field
|
||||
?.querySelector(".field-label")
|
||||
?.textContent?.trim();
|
||||
input.setAttribute(
|
||||
"aria-label",
|
||||
input.getAttribute("aria-label") ||
|
||||
milestone ||
|
||||
fieldLabel ||
|
||||
input.placeholder ||
|
||||
"Plan value",
|
||||
);
|
||||
input.setAttribute(
|
||||
"aria-describedby",
|
||||
[input.getAttribute("aria-describedby"), id].filter(Boolean).join(" "),
|
||||
);
|
||||
const host = field || input.closest(".unit-input") || input;
|
||||
let wrapper = host?.parentElement?.classList.contains("field-help-host")
|
||||
? host.parentElement
|
||||
: null;
|
||||
if (!wrapper && host) {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "field-help-host";
|
||||
host.replaceWith(wrapper);
|
||||
wrapper.append(host);
|
||||
}
|
||||
// Labels cannot contain another interactive control, so the help button is a sibling.
|
||||
wrapper?.append(button, help);
|
||||
}
|
||||
}
|
||||
|
||||
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
|
||||
// so its radios don't fight the screen form's identically-named radios for exclusivity.
|
||||
// Every sync pass therefore has to reach both containers explicitly.
|
||||
@@ -235,7 +144,6 @@ function updateBlendVisibility(mode) {
|
||||
function renderBlend() {
|
||||
renderBlendPrintRows();
|
||||
renderBlendCards();
|
||||
wireFieldHelp(document.getElementById("blend-cards"));
|
||||
updateBlendTotal();
|
||||
}
|
||||
|
||||
@@ -293,7 +201,6 @@ function renderActuatorTimeline() {
|
||||
function renderActuators() {
|
||||
renderActuatorPrintRows();
|
||||
renderActuatorTimeline();
|
||||
wireFieldHelp(document.getElementById("actuator-timeline"));
|
||||
}
|
||||
|
||||
// ---- populate every control in the form from state.plan
|
||||
@@ -493,10 +400,41 @@ function renderCurve(ledger) {
|
||||
);
|
||||
}
|
||||
|
||||
// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
|
||||
// is "0:00", since charge is always t=0) — compare against those defaults, not against "",
|
||||
// so a fresh plan doesn't read as already having roast-day data.
|
||||
const BLANK_PLAN = blankPlan();
|
||||
|
||||
// True once any value in `obj` differs from the same path in `blank` — used to lift the
|
||||
// "phase-later" muting off Roast Log / After the Roast the moment the user has actually started
|
||||
// using them, without ever hiding the plan column they exist to compare against.
|
||||
function hasVal(obj, blank) {
|
||||
if (obj == null) return false;
|
||||
return Object.keys(obj).some((key) => {
|
||||
const v = obj[key];
|
||||
return typeof v === "object" && v !== null
|
||||
? hasVal(v, blank?.[key])
|
||||
: v !== "" && v != null && v !== blank?.[key];
|
||||
});
|
||||
}
|
||||
|
||||
export function recompute() {
|
||||
const ledger = renderLedger();
|
||||
renderCurve(ledger);
|
||||
renderBandMarkers();
|
||||
document
|
||||
.getElementById("sec-roastlog")
|
||||
?.classList.toggle(
|
||||
"has-data",
|
||||
hasVal(state.plan.planActual, BLANK_PLAN.planActual),
|
||||
);
|
||||
document
|
||||
.getElementById("sec-after")
|
||||
?.classList.toggle(
|
||||
"has-data",
|
||||
hasVal(state.plan.afterRoast, BLANK_PLAN.afterRoast),
|
||||
);
|
||||
lotPicker?.updateConsumeRow();
|
||||
autosave();
|
||||
}
|
||||
|
||||
@@ -514,10 +452,16 @@ function setSaveStatus(status) {
|
||||
}[status] || "Not saved yet";
|
||||
}
|
||||
let syncQueue = Promise.resolve();
|
||||
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly
|
||||
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local
|
||||
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and
|
||||
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that
|
||||
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session)
|
||||
// must check remotePlanId themselves afterward, which they already do.
|
||||
function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||
if (!navigator.onLine || !csrfToken()) {
|
||||
setSaveStatus("local");
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
syncQueue = syncQueue.then(async () => {
|
||||
try {
|
||||
@@ -532,10 +476,11 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||
if (!response.ok) throw new Error("sync_failed");
|
||||
const body = await response.json();
|
||||
remotePlanId = body.plan.id;
|
||||
draftSyncedAt = body.plan.updated_at;
|
||||
if (storageKey)
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
|
||||
);
|
||||
history.replaceState(
|
||||
null,
|
||||
@@ -544,8 +489,10 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
|
||||
);
|
||||
setSaveStatus("saved");
|
||||
await loadPlans();
|
||||
return true;
|
||||
} catch {
|
||||
setSaveStatus("failed");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return syncQueue;
|
||||
@@ -557,14 +504,13 @@ async function flushCurrentPlan() {
|
||||
if (storageKey)
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ plan: snapshot, remotePlanId }),
|
||||
JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
|
||||
);
|
||||
} catch {
|
||||
setSaveStatus("failed");
|
||||
return false;
|
||||
}
|
||||
await syncPlan(snapshot);
|
||||
return true;
|
||||
return await syncPlan(snapshot);
|
||||
}
|
||||
function autosave() {
|
||||
setSaveStatus("saving");
|
||||
@@ -594,6 +540,7 @@ function loadFromStorage(userId) {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft?.plan) {
|
||||
remotePlanId = draft.remotePlanId || null;
|
||||
draftSyncedAt = draft.syncedAt || null;
|
||||
return draft.plan;
|
||||
}
|
||||
return draft; // legacy v2 draft: retain it once, then upgrade on next save
|
||||
@@ -611,6 +558,7 @@ function clearDraft() {
|
||||
}
|
||||
storageKey = null;
|
||||
remotePlanId = null;
|
||||
draftSyncedAt = null;
|
||||
}
|
||||
|
||||
function cultivarAutofill(name) {
|
||||
@@ -670,16 +618,18 @@ function wireDrawers() {
|
||||
panel.querySelector("button, input, [href]")?.focus();
|
||||
}
|
||||
for (const [buttonId, panelId] of [
|
||||
["btn-toggle-prefill", "panel-prefill"],
|
||||
["btn-toggle-alog", "panel-alog"],
|
||||
["btn-plans", "panel-plans"],
|
||||
["btn-settings", "panel-settings"],
|
||||
["nav-prefill", "panel-prefill"],
|
||||
["nav-alog", "panel-alog"],
|
||||
["nav-plans", "panel-plans"],
|
||||
["nav-settings", "panel-settings"],
|
||||
["nav-import-export", "panel-import-export"],
|
||||
])
|
||||
document
|
||||
.getElementById(buttonId)
|
||||
.addEventListener("click", (event) =>
|
||||
open(document.getElementById(panelId), event.currentTarget),
|
||||
);
|
||||
.addEventListener("click", (event) => {
|
||||
document.getElementById("side-nav")?.classList.remove("mobile-open");
|
||||
open(document.getElementById(panelId), event.currentTarget);
|
||||
});
|
||||
overlay.addEventListener("click", closeAll);
|
||||
for (const btn of document.querySelectorAll("[data-close-drawer]"))
|
||||
btn.addEventListener("click", closeAll);
|
||||
@@ -738,6 +688,7 @@ async function loadPlans() {
|
||||
async function selectPlan(plan) {
|
||||
if (!(await flushCurrentPlan())) return;
|
||||
remotePlanId = plan.id;
|
||||
draftSyncedAt = plan.updated_at;
|
||||
state.plan = { ...blankPlan(), ...plan.plan };
|
||||
history.replaceState(
|
||||
null,
|
||||
@@ -748,6 +699,11 @@ async function selectPlan(plan) {
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
// renderFormFromPlan() sets the lot <select>'s value, but that only sticks if the option is
|
||||
// already in the DOM — re-render the picker's own option list against the newly-loaded
|
||||
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
|
||||
// leave the dropdown showing the previous plan's selection or a blank state.
|
||||
lotPicker?.renderOptions();
|
||||
document.querySelector("[data-close-drawer]")?.click();
|
||||
}
|
||||
async function newPlan() {
|
||||
@@ -755,12 +711,14 @@ async function newPlan() {
|
||||
return;
|
||||
if (!(await flushCurrentPlan())) return;
|
||||
remotePlanId = null;
|
||||
draftSyncedAt = null;
|
||||
state.plan = blankPlan();
|
||||
history.replaceState(null, "", "/app");
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
lotPicker?.renderOptions();
|
||||
}
|
||||
|
||||
function wireToolbar() {
|
||||
@@ -864,6 +822,10 @@ function wirePwa() {
|
||||
renderConnection();
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
// A brand-new visitor has no controller yet, so the *first* activation firing
|
||||
// "controllerchange" is not an update — only a page that was already controlled by a
|
||||
// previous service worker should reload when a new one takes over.
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
@@ -882,9 +844,9 @@ function wirePwa() {
|
||||
console.warn("Service worker registration failed:", error),
|
||||
);
|
||||
});
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () =>
|
||||
location.reload(),
|
||||
);
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (hadController) location.reload();
|
||||
});
|
||||
}
|
||||
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||
if (!(await flushCurrentPlan())) return;
|
||||
@@ -927,21 +889,36 @@ async function init() {
|
||||
const { user } = await meResponse.json();
|
||||
document.getElementById("account-email").textContent = user.email;
|
||||
document.getElementById("settings-email").textContent = user.email;
|
||||
document.getElementById("account-summary").textContent =
|
||||
user.email.split("@")[0];
|
||||
document.getElementById("nav-user-avatar").textContent = user.email
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
if (user.role === "admin")
|
||||
document.getElementById("menu-admin").classList.remove("hidden");
|
||||
document.getElementById("nav-admin").classList.remove("hidden");
|
||||
const localDraft = loadFromStorage(user.id);
|
||||
state.plan = localDraft ?? blankPlan();
|
||||
const draftRemoteId = remotePlanId;
|
||||
const draftSyncedAtAtLoad = draftSyncedAt;
|
||||
await loadPlans();
|
||||
const requestedId = new URLSearchParams(location.search).get("plan");
|
||||
const selected = plans.find((plan) => plan.id === requestedId);
|
||||
if (selected) {
|
||||
state.plan = selected.plan;
|
||||
// Prefer the local draft only when it targets this exact plan AND was last confirmed
|
||||
// synced at or after the server's own updated_at — otherwise another device's newer
|
||||
// edit (or a draft from before sync tracking existed) would be silently discarded.
|
||||
const draftIsCurrent =
|
||||
selected &&
|
||||
draftRemoteId === selected.id &&
|
||||
draftSyncedAtAtLoad &&
|
||||
new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
|
||||
if (selected && !draftIsCurrent) {
|
||||
state.plan = { ...blankPlan(), ...selected.plan };
|
||||
remotePlanId = selected.id;
|
||||
draftSyncedAt = selected.updated_at;
|
||||
} else if (selected) {
|
||||
remotePlanId = selected.id;
|
||||
} else if (!localDraft && !requestedId && plans[0]) {
|
||||
state.plan = plans[0].plan;
|
||||
state.plan = { ...blankPlan(), ...plans[0].plan };
|
||||
remotePlanId = plans[0].id;
|
||||
draftSyncedAt = plans[0].updated_at;
|
||||
}
|
||||
} catch {
|
||||
// The cached app shell contains no user data. A successful online sign-in records the
|
||||
@@ -957,10 +934,11 @@ async function init() {
|
||||
renderBlend();
|
||||
renderActuators();
|
||||
wireCultivarDatalist();
|
||||
wireFieldHelp();
|
||||
wireWhyPanels();
|
||||
renderBandRanges();
|
||||
renderFormFromPlan();
|
||||
wireForm();
|
||||
initSideNav();
|
||||
wireDrawers();
|
||||
wireToolbar();
|
||||
wirePwa();
|
||||
@@ -974,7 +952,65 @@ async function init() {
|
||||
});
|
||||
initAlogPanel({ state, recompute });
|
||||
initPrint({ beforePrint: renderFormFromPlan });
|
||||
lotPicker = initLotPicker({
|
||||
state,
|
||||
recompute,
|
||||
flushCurrentPlan,
|
||||
getRemotePlanId: () => remotePlanId,
|
||||
});
|
||||
wireCuppingLink();
|
||||
recompute();
|
||||
}
|
||||
|
||||
function wireCuppingLink() {
|
||||
const button = document.getElementById("btn-open-cupping");
|
||||
const note = document.getElementById("cupping-link-note");
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (!(await flushCurrentPlan())) {
|
||||
note.textContent = "Could not sync the plan. Try again.";
|
||||
return;
|
||||
}
|
||||
if (!remotePlanId) {
|
||||
note.textContent = "Sync the plan first, then open a cupping session.";
|
||||
return;
|
||||
}
|
||||
const existing = await protectedFetch(
|
||||
`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`,
|
||||
)
|
||||
.then((r) => (r.ok ? r.json() : { sessions: [] }))
|
||||
.catch(() => ({ sessions: [] }));
|
||||
if (existing.sessions?.length) {
|
||||
location.assign(`/cupping?session=${existing.sessions[0].id}`);
|
||||
return;
|
||||
}
|
||||
let created;
|
||||
try {
|
||||
created = await protectedFetch("/api/cupping", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ roastPlanId: remotePlanId }),
|
||||
}).then((r) => r.json());
|
||||
} catch {
|
||||
note.textContent = "Could not reach the server. Try again.";
|
||||
return;
|
||||
}
|
||||
if (created.session) location.assign(`/cupping?session=${created.session.id}`);
|
||||
else note.textContent = "Could not start a cupping session.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
// Non-blocking: show whether a session already exists without forcing a sync first.
|
||||
if (remotePlanId)
|
||||
protectedFetch(`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
if (body?.sessions?.length)
|
||||
note.textContent = `Session exists — ${body.sessions[0].totalScore.toFixed(2)}`;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user