import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { renderFlavorWheel } from "./flavor-wheel.js?v=__ASSET_VERSION__";
import {
SCORE_ATTRS,
SCORE_LABELS,
TICK_ATTRS,
TICK_LABELS,
FLAVOR_TAXONOMY,
MAX_FLAVOR_TAGS,
MAX_CUP_COUNT,
computeTotalScore,
} from "/shared/cupping.js?v=__ASSET_VERSION__";
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 = `
| No cupping sessions yet. |
`;
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 = `| Could not load sessions. |
`;
}
}
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.name || 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();
renderSubtitle();
scheduleSave();
});
}
function renderSubtitle() {
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
}
// ── 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);
}
renderWheel();
renderFlavorFamilies();
renderFlavorChips();
scheduleSave();
}
function renderWheel() {
const svg = document.getElementById("flavor-wheel");
if (!svg) return;
renderFlavorWheel(
svg,
FLAVOR_TAXONOMY,
data.flavor_tags,
(tag, on) => toggleFlavorTag(tag, on),
data.flavor_tags.length >= MAX_FLAVOR_TAGS,
);
}
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;
// One hue per attribute so the wheel reads as ten distinct spokes, not a grey web.
const AXIS_COLOR = (i) => `hsl(${(i * 36 + 8) % 360} 46% 40%)`;
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",
"font-weight": "600",
fill: AXIS_COLOR(i),
});
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;
const points = radarPoints();
shape.setAttribute("points", points.map((p) => `${p.x},${p.y}`).join(" "));
// Colored vertex dots matching each axis label.
const svg = document.getElementById("cup-radar");
for (const old of svg.querySelectorAll("[data-vertex]")) old.remove();
points.forEach((p, i) => {
const dot = svgEl("circle", {
cx: p.x,
cy: p.y,
r: 3.2,
fill: AXIS_COLOR(i),
"data-vertex": "1",
});
svg.append(dot);
});
}
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";
renderSubtitle();
// Sessions created without a plan can attach one later (same PUT the autosave uses) —
// otherwise they're stuck titled "Untitled" forever.
if (!body.session.roastPlanId) {
const subtitle = document.getElementById("cupping-subtitle");
try {
const { plans } = await api("/api/plans");
if (plans.length) {
const wrap = document.createElement("span");
wrap.append(" · Plan: ");
const select = document.createElement("select");
select.className = "field-input sm";
select.style.display = "inline-block";
select.style.width = "auto";
select.append(
Object.assign(document.createElement("option"), {
value: "",
textContent: "— none —",
}),
...plans.map((p) =>
Object.assign(document.createElement("option"), {
value: p.id,
textContent: p.name || p.plan?.fields?.["0.1"] || "Untitled plan",
}),
),
);
select.addEventListener("change", async () => {
try {
await api(`/api/cupping/${sessionId}`, {
method: "PUT",
body: JSON.stringify({ data, roastPlanId: select.value || null }),
});
showToast("Plan attached.");
location.reload();
} catch (error) {
showToast(error.message, "fail");
}
});
wrap.append(select);
subtitle.after(wrap);
}
} catch {
/* attaching later is a convenience — the session still works without it */
}
}
wireCupCount();
renderScoreRows();
renderTickRows();
renderDefectRows();
renderWheel();
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();