Add Equipment page: owned brewers and grinders filter the Brews UI
Test and deploy / test-and-deploy (push) Successful in 59s

- 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]>
This commit is contained in:
Shane Maynard
2026-08-09 08:08:32 -04:00
co-authored by Claude Fable 5
parent 3bceb44ae3
commit e62b9601a2
10 changed files with 433 additions and 4 deletions
+54 -1
View File
@@ -14,6 +14,10 @@ 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) =>
@@ -45,17 +49,29 @@ function silhouetteSvg(methodKey, size = 42) {
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 BREW_METHODS.filter((m) => m.category === category.key)) {
for (const method of inCategory) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "brewer-tile";
@@ -72,6 +88,29 @@ function renderBrewerPicker() {
}
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) {
@@ -269,6 +308,8 @@ 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 ?? "";
@@ -368,7 +409,19 @@ 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);