Files
roast_command_center/public/js/cupping.js
snowspeeder 59645e59b5 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.
2026-07-30 14:47:15 -04:00

587 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();