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
+6 -2
View File
@@ -107,8 +107,12 @@
>
<label class="field"
><span class="field-label">Grinder</span
><input class="field-input" name="grinder" placeholder="e.g. Comandante C40"
/></label>
><input
class="field-input"
name="grinder"
list="grinder-options"
placeholder="e.g. Comandante C40"
/><datalist id="grinder-options"></datalist></label>
<label class="field"
><span class="field-label">Grind setting</span
><input class="field-input" name="grindSetting" placeholder="e.g. 22 clicks"
+102
View File
@@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Equipment — Roast Planner</title>
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
</button>
<div class="brand">
<div class="brand-text">
<h1>Equipment</h1>
<p class="brand-sub">
The brewers and grinders you own — Brews only offers these
</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content">
<section class="panel-card" id="brewers-card">
<div class="panel-head">
<h2>Your brewers</h2>
<span class="muted" id="brewers-count"></span>
</div>
<div class="panel-body">
<p class="field-note" style="margin-top:0">
Tap the brewers you own. With none selected, the Brews page
shows every method.
</p>
<div id="gear-brewer-picker"></div>
</div>
</section>
<section class="panel-card" id="grinders-card">
<div class="panel-head"><h2>Your grinders</h2></div>
<div class="panel-body">
<form
id="grinder-form"
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"
>
<input
class="text-input"
id="grinder-name"
placeholder="e.g. Comandante C40, DF64"
style="max-width:280px"
/>
<button class="ghost-btn" type="submit">Add grinder</button>
</form>
<ul class="lib-list" id="grinder-list" style="margin-top:10px"></ul>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/gear.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+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);
+154
View File
@@ -0,0 +1,154 @@
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,
} from "/shared/brew-data.js?v=__ASSET_VERSION__";
const SVG_NS = "http://www.w3.org/2000/svg";
let gear = { brewers: [], grinders: [] };
let saveTimer = null;
function silhouetteSvg(methodKey) {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 64 64");
svg.setAttribute("width", 42);
svg.setAttribute("height", 42);
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;
}
// Saves shortly after the last toggle rather than needing a Save button — an equipment page
// is set-and-forget, so every change should just stick.
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
const body = await api("/api/gear", {
method: "PUT",
body: JSON.stringify(gear),
});
gear = body.gear;
showToast("Equipment saved.");
} catch (error) {
showToast(error.message, "fail");
}
}, 500);
}
function renderCount() {
document.getElementById("brewers-count").textContent = gear.brewers.length
? `${gear.brewers.length} selected`
: "none selected — Brews shows all";
}
function renderBrewerPicker() {
const mount = document.getElementById("gear-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";
const owned = () => gear.brewers.includes(method.key);
tile.classList.toggle("selected", owned());
tile.setAttribute("aria-pressed", String(owned()));
tile.append(silhouetteSvg(method.key));
const name = document.createElement("span");
name.className = "brewer-name";
name.textContent = method.name;
tile.append(name);
tile.addEventListener("click", () => {
gear.brewers = owned()
? gear.brewers.filter((k) => k !== method.key)
: [...gear.brewers, method.key];
tile.classList.toggle("selected", owned());
tile.setAttribute("aria-pressed", String(owned()));
renderCount();
scheduleSave();
});
grid.append(tile);
}
mount.append(grid);
}
renderCount();
}
function renderGrinders() {
const list = document.getElementById("grinder-list");
if (!gear.grinders.length) {
list.replaceChildren(
Object.assign(document.createElement("li"), {
textContent: "No grinders yet.",
className: "muted",
}),
);
return;
}
list.replaceChildren(
...gear.grinders.map((grinder) => {
const li = document.createElement("li");
li.style.display = "flex";
li.style.alignItems = "center";
li.style.gap = "10px";
const name = document.createElement("span");
name.textContent = grinder;
const remove = document.createElement("button");
remove.className = "ghost-btn small";
remove.type = "button";
remove.textContent = "Remove";
remove.addEventListener("click", () => {
gear.grinders = gear.grinders.filter((g) => g !== grinder);
renderGrinders();
scheduleSave();
});
li.append(name, remove);
return li;
}),
);
}
document.getElementById("grinder-form").addEventListener("submit", (event) => {
event.preventDefault();
const input = document.getElementById("grinder-name");
const name = input.value.trim();
if (!name) return;
if (!gear.grinders.includes(name)) gear.grinders = [...gear.grinders, name];
input.value = "";
renderGrinders();
scheduleSave();
});
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 {
showToast("Could not load equipment.", "fail");
}
renderBrewerPicker();
renderGrinders();
}
init();
+1
View File
@@ -19,6 +19,7 @@ const NAV_GROUPS = [
items: [
{ href: "/brews", icon: "◉", label: "Brews" },
{ href: "/beans", icon: "◍", label: "Beans" },
{ href: "/gear", icon: "⚒", label: "Equipment" },
],
},
{