Files
roast_command_center/public/js/brews.js
T
Shane MaynardandClaude Fable 5 e62b9601a2
Test and deploy / test-and-deploy (push) Successful in 59s
Add Equipment page: owned brewers and grinders filter the Brews UI
- user_gear table (migration 008) + GET/PUT /api/gear (brewer keys
  validated against the taxonomy, grinders deduped/trimmed)
- /gear page: silhouette multi-select for owned brewers (autosaves) and
  a grinder list; nav gains Brewing → Equipment
- Brews page: with gear configured, the picker shows only owned brewers
  (plus the edited brew's method) with a show-all toggle; grinder field
  suggests your grinders; empty-gear state links to /gear
- Included in full backups, per-user export, and the OpenAPI spec

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-09 08:08:32 -04:00

440 lines
15 KiB
JavaScript

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;
// Owned equipment from /gear: when brewers are configured, the picker shows only those
// (with a show-all escape hatch), and grinders become input suggestions.
let gear = { brewers: [], grinders: [] };
let showAllBrewers = false;
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 visibleMethods() {
if (!gear.brewers.length || showAllBrewers) return BREW_METHODS;
// An old brew being edited may use a brewer that's since been removed from the gear list —
// keep its tile visible so the selection isn't silently unrepresentable.
return BREW_METHODS.filter(
(m) => gear.brewers.includes(m.key) || m.key === selectedMethod,
);
}
function renderBrewerPicker() {
const mount = document.getElementById("brewer-picker");
mount.replaceChildren();
const methods = visibleMethods();
for (const category of BREW_CATEGORIES) {
const inCategory = methods.filter((m) => m.category === category.key);
if (!inCategory.length) continue;
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 inCategory) {
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);
}
if (gear.brewers.length) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ghost-btn small";
toggle.style.marginTop = "10px";
toggle.textContent = showAllBrewers
? "Show only my brewers"
: "Show all brewers…";
toggle.addEventListener("click", () => {
showAllBrewers = !showAllBrewers;
renderBrewerPicker();
});
mount.append(toggle);
} else {
const hint = document.createElement("p");
hint.className = "field-note";
hint.style.marginTop = "10px";
const link = document.createElement("a");
link.href = "/gear";
link.textContent = "Set up your equipment";
hint.append(link, " to only see the brewers you own here.");
mount.append(hint);
}
}
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 = `<tr><td colspan="6" class="empty-state">No brews logged yet. Pick a brewer above and log your first cup.</td></tr>`;
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 =
`<tr><td colspan="6" class="empty-state">Could not load brews.</td></tr>`;
}
}
function startEdit(brew) {
editingId = brew.id;
const form = document.getElementById("brew-form");
form.beanId.value = brew.beanId ?? "";
selectedMethod = brew.method;
renderBrewerPicker(); // the edited brew's method may be outside the owned-gear filter
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;
try {
gear = (await api("/api/gear")).gear;
} catch {
/* no gear configured — picker shows everything */
}
renderBrewerPicker();
const grinderOptions = document.getElementById("grinder-options");
if (grinderOptions)
grinderOptions.replaceChildren(
...gear.grinders.map((g) =>
Object.assign(document.createElement("option"), { value: g }),
),
);
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();