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 = `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.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();