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 { BREW_CATEGORIES, BREW_METHODS, BREW_SILHOUETTES, findBrewMethod, } from "/shared/brew-data.js?v=__ASSET_VERSION__"; const SVG_NS = "http://www.w3.org/2000/svg"; let beans = []; let brews = []; let editingId = null; let selectedMethod = null; let beanFilter = null; const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—"); const fmtTime = (s) => s == null ? null : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; function parseTime(str) { if (!str || !str.trim()) return null; const m = str.trim().match(/^(\d+):(\d{1,2})$/); if (m) return Number(m[1]) * 60 + Number(m[2]); const n = Number(str); return Number.isInteger(n) && n >= 0 ? n : undefined; // undefined = unparseable } function silhouetteSvg(methodKey, size = 42) { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", "0 0 64 64"); svg.setAttribute("width", size); svg.setAttribute("height", size); svg.setAttribute("aria-hidden", "true"); for (const d of BREW_SILHOUETTES[methodKey] ?? []) { const path = document.createElementNS(SVG_NS, "path"); path.setAttribute("d", d); path.setAttribute("fill", "currentColor"); svg.append(path); } return svg; } const isEspresso = () => findBrewMethod(selectedMethod)?.category === "espresso"; function renderBrewerPicker() { const mount = document.getElementById("brewer-picker"); mount.replaceChildren(); for (const category of BREW_CATEGORIES) { const title = document.createElement("div"); title.className = "brewer-cat-title"; title.textContent = category.name; mount.append(title); const grid = document.createElement("div"); grid.className = "brewer-grid"; for (const method of BREW_METHODS.filter((m) => m.category === category.key)) { const tile = document.createElement("button"); tile.type = "button"; tile.className = "brewer-tile"; tile.dataset.method = method.key; tile.classList.toggle("selected", method.key === selectedMethod); tile.setAttribute("aria-pressed", String(method.key === selectedMethod)); tile.append(silhouetteSvg(method.key)); const name = document.createElement("span"); name.className = "brewer-name"; name.textContent = method.name; tile.append(name); tile.addEventListener("click", () => selectMethod(method.key)); grid.append(tile); } mount.append(grid); } } function selectMethod(key) { selectedMethod = key; for (const tile of document.querySelectorAll(".brewer-tile")) { const on = tile.dataset.method === key; tile.classList.toggle("selected", on); tile.setAttribute("aria-pressed", String(on)); } // Espresso-style methods log yield-out instead of brew water and have no bloom. const espresso = isEspresso(); for (const el of document.querySelectorAll("[data-espresso-show]")) el.classList.toggle("hidden", !espresso); for (const el of document.querySelectorAll("[data-espresso-hide]")) el.classList.toggle("hidden", espresso); updateRatio(); } function updateRatio() { const form = document.getElementById("brew-form"); const dose = Number.parseFloat(form.doseG.value); const out = Number.parseFloat(isEspresso() ? form.yieldG.value : form.waterG.value); document.getElementById("brew-ratio").value = Number.isFinite(dose) && dose > 0 && Number.isFinite(out) && out > 0 ? `1:${Math.round((out / dose) * 10) / 10}` : ""; } function updateRatingPill() { const value = document.getElementById("brew-rating").value; const pill = document.getElementById("rating-value"); pill.textContent = value; pill.classList.toggle("good", Number(value) >= 7.5); pill.classList.toggle("poor", Number(value) <= 4); } function renderBeanSelect() { const select = document.getElementById("brew-bean-select"); const current = select.value; select.replaceChildren( Object.assign(document.createElement("option"), { value: "", textContent: "— no bean selected —", }), ...beans .filter((b) => !b.archived || b.id === current) .map((bean) => { const option = document.createElement("option"); option.value = bean.id; const remaining = bean.remainingWeightG == null ? "" : ` (${Math.round(bean.remainingWeightG)} g left)`; option.textContent = `${bean.name}${bean.roaster ? ` — ${bean.roaster}` : ""}${remaining}`; return option; }), ); select.value = current; } function ratingPill(rating) { const pill = document.createElement("span"); pill.className = "rating-pill"; if (rating == null) pill.textContent = "—"; else { pill.textContent = String(rating); if (rating >= 7.5) pill.classList.add("good"); if (rating <= 4) pill.classList.add("poor"); } return pill; } function recipeText(brew) { const method = findBrewMethod(brew.method); const parts = []; const out = method?.category === "espresso" ? brew.yieldG : brew.waterG; if (brew.doseG != null && out != null) parts.push(`${brew.doseG} g → ${out} g (1:${Math.round((out / brew.doseG) * 10) / 10})`); else if (brew.doseG != null) parts.push(`${brew.doseG} g`); if (brew.waterTempC != null) parts.push(`${brew.waterTempC}°C`); if (brew.brewTimeS != null) parts.push(fmtTime(brew.brewTimeS)); return parts.join(" · ") || "—"; } function renderBrews() { const body = document.getElementById("brews-body"); const visible = beanFilter ? brews.filter((b) => b.beanId === beanFilter) : brews; document .getElementById("clear-bean-filter") .classList.toggle("hidden", !beanFilter); if (!visible.length) { body.innerHTML = `No brews logged yet. Pick a brewer above and log your first cup.`; return; } body.replaceChildren( ...visible.map((brew) => { const tr = document.createElement("tr"); const whenCell = document.createElement("td"); whenCell.textContent = fmtDate(brew.brewedAt); const methodCell = document.createElement("td"); const chip = document.createElement("span"); chip.className = "method-chip"; chip.append(silhouetteSvg(brew.method, 20)); const methodName = document.createElement("span"); methodName.textContent = findBrewMethod(brew.method)?.name ?? brew.method; chip.append(methodName); methodCell.append(chip); const beanCell = document.createElement("td"); beanCell.textContent = brew.beanName ?? "—"; const recipeCell = document.createElement("td"); recipeCell.textContent = recipeText(brew); if (brew.recipe) { const steps = document.createElement("div"); steps.className = "muted"; steps.style.fontSize = "11.5px"; steps.style.maxWidth = "340px"; steps.textContent = brew.recipe; recipeCell.append(steps); } if (brew.tastingNotes) { const notes = document.createElement("div"); notes.className = "muted"; notes.style.fontSize = "11.5px"; notes.style.maxWidth = "340px"; notes.textContent = brew.tastingNotes; recipeCell.append(notes); } const ratingCell = document.createElement("td"); ratingCell.append(ratingPill(brew.rating)); const actions = document.createElement("td"); actions.className = "data-table-actions"; const editBtn = document.createElement("button"); editBtn.className = "ghost-btn small"; editBtn.type = "button"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", () => startEdit(brew)); const againBtn = document.createElement("button"); againBtn.className = "ghost-btn small"; againBtn.type = "button"; againBtn.textContent = "Brew again"; againBtn.title = "Copy this brew's recipe into the form as a new brew"; againBtn.addEventListener("click", () => { startEdit(brew); editingId = null; document.getElementById("brew-form-title").textContent = "Log a brew"; document.getElementById("brew-form-submit").textContent = "Save brew"; document.getElementById("brew-form-cancel").classList.add("hidden"); }); const deleteBtn = document.createElement("button"); deleteBtn.className = "ghost-btn small"; deleteBtn.type = "button"; deleteBtn.textContent = "Delete"; deleteBtn.addEventListener("click", async () => { if (!confirm("Delete this brew? This cannot be undone.")) return; try { await api(`/api/brews/${brew.id}`, { method: "DELETE" }); showToast("Brew deleted."); if (editingId === brew.id) cancelEdit(); await Promise.all([loadBrews(), loadBeans()]); } catch (error) { showToast(error.message, "fail"); } }); actions.append(editBtn, againBtn, deleteBtn); tr.append(whenCell, methodCell, beanCell, recipeCell, ratingCell, actions); return tr; }), ); } async function loadBeans() { try { beans = (await api("/api/beans")).beans; renderBeanSelect(); } catch { /* bean select stays empty; brews still work without beans */ } } async function loadBrews() { try { brews = (await api("/api/brews")).brews; renderBrews(); } catch { document.getElementById("brews-body").innerHTML = `Could not load brews.`; } } function startEdit(brew) { editingId = brew.id; const form = document.getElementById("brew-form"); form.beanId.value = brew.beanId ?? ""; selectMethod(brew.method); form.doseG.value = brew.doseG ?? ""; form.waterG.value = brew.waterG ?? ""; form.yieldG.value = brew.yieldG ?? ""; form.waterTempC.value = brew.waterTempC ?? ""; form.grinder.value = brew.grinder; form.grindSetting.value = brew.grindSetting; form.brewTime.value = fmtTime(brew.brewTimeS) ?? ""; form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? ""; form.rating.value = brew.rating ?? 5; form.recipe.value = brew.recipe; form.tastingNotes.value = brew.tastingNotes; form.notes.value = brew.notes; updateRatio(); updateRatingPill(); document.getElementById("brew-form-title").textContent = "Edit brew"; document.getElementById("brew-form-submit").textContent = "Save changes"; document.getElementById("brew-form-cancel").classList.remove("hidden"); document.getElementById("brew-form-card").scrollIntoView({ behavior: "smooth" }); } function cancelEdit() { editingId = null; const form = document.getElementById("brew-form"); form.reset(); updateRatio(); updateRatingPill(); document.getElementById("brew-form-title").textContent = "Log a brew"; document.getElementById("brew-form-submit").textContent = "Save brew"; document.getElementById("brew-form-cancel").classList.add("hidden"); } document.getElementById("brew-form").addEventListener("submit", async (event) => { event.preventDefault(); if (!selectedMethod) { showToast("Pick a brewer first.", "fail"); return; } const form = event.target; const brewTimeS = parseTime(form.brewTime.value); const bloomTimeS = parseTime(form.bloomTime.value); if (brewTimeS === undefined || bloomTimeS === undefined) { showToast("Times must look like m:ss (e.g. 2:45).", "fail"); return; } const num = (v) => (v === "" ? null : Number(v)); const payload = { beanId: form.beanId.value || null, method: selectedMethod, doseG: num(form.doseG.value), waterG: num(form.waterG.value), yieldG: num(form.yieldG.value), waterTempC: num(form.waterTempC.value), grinder: form.grinder.value, grindSetting: form.grindSetting.value, brewTimeS, bloomTimeS, rating: Number(form.rating.value), recipe: form.recipe.value, tastingNotes: form.tastingNotes.value, notes: form.notes.value, }; const submit = document.getElementById("brew-form-submit"); submit.disabled = true; try { if (editingId) { await api(`/api/brews/${editingId}`, { method: "PUT", body: JSON.stringify(payload) }); showToast("Brew updated."); } else { await api("/api/brews", { method: "POST", body: JSON.stringify(payload) }); showToast("Brew logged."); } cancelEdit(); await Promise.all([loadBrews(), loadBeans()]); } catch (error) { showToast(error.message, "fail"); } finally { submit.disabled = false; } }); document.getElementById("brew-form-cancel").addEventListener("click", cancelEdit); document.getElementById("brew-form").addEventListener("input", (event) => { if (["doseG", "waterG", "yieldG"].includes(event.target.name)) updateRatio(); if (event.target.name === "rating") updateRatingPill(); }); document.getElementById("clear-bean-filter").addEventListener("click", () => { beanFilter = null; renderBrews(); }); 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; renderBrewerPicker(); updateRatingPill(); await Promise.all([loadBeans(), loadBrews()]); const params = new URLSearchParams(location.search); const requestedBean = params.get("bean"); if (requestedBean && beans.some((b) => b.id === requestedBean)) { if (params.get("new")) { document.getElementById("brew-bean-select").value = requestedBean; } else { beanFilter = requestedBean; renderBrews(); } } } init();