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
+8
View File
@@ -0,0 +1,8 @@
-- Additive only. Per-user owned equipment: which brewers the Brews page should offer
-- (subset of the shared brew-method keys) and the user's grinders (free-text names).
CREATE TABLE user_gear (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
brewers jsonb NOT NULL DEFAULT '[]',
grinders jsonb NOT NULL DEFAULT '[]',
updated_at timestamptz NOT NULL DEFAULT now()
);
+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" },
],
},
{
+56
View File
@@ -48,6 +48,7 @@ const PUBLIC_SHELL_FILES = new Set([
"/roasts.html",
"/beans.html",
"/brews.html",
"/gear.html",
"/api-docs.html",
]);
@@ -142,6 +143,7 @@ export function createApp({
req.path === "/roasts" ||
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/gear" ||
req.path === "/api-docs"
)
res.set("Cache-Control", "no-store, private");
@@ -422,6 +424,7 @@ export function createApp({
for (const [route, file] of [
["/beans", "beans.html"],
["/brews", "brews.html"],
["/gear", "gear.html"],
["/api-docs", "api-docs.html"],
]) {
app.get(route, async (req, res, next) => {
@@ -2390,6 +2393,7 @@ export function createApp({
],
],
["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
["user_gear", ["user_id", "brewers", "grinders", "updated_at"]],
["audit_events", ["id", "actor_user_id", "action", "target", "created_at"]],
];
const BACKUP_VERSION = 1;
@@ -2718,6 +2722,58 @@ export function createApp({
.json({ ok: false, code: "unparseable_alog", error: err.message });
}
});
// ─── Equipment (owned brewers + grinders) ──────────────────────────────
app.get("/api/gear", requireAuth, async (req, res, next) => {
try {
const row = (
await db.query("SELECT brewers,grinders FROM user_gear WHERE user_id=$1", [
req.user.id,
])
).rows[0];
res.json({
ok: true,
gear: { brewers: row?.brewers ?? [], grinders: row?.grinders ?? [] },
});
} catch (e) {
next(e);
}
});
app.put("/api/gear", requireAuth, csrf, async (req, res, next) => {
try {
const rawBrewers = req.body.brewers;
const rawGrinders = req.body.grinders;
if (!Array.isArray(rawBrewers) || !Array.isArray(rawGrinders))
return res.status(400).json({ ok: false, code: "bad_gear" });
const brewers = [...new Set(rawBrewers)];
if (
brewers.length > BREW_METHODS.length ||
!brewers.every((k) => BREW_METHOD_KEYS.has(k))
)
return res
.status(400)
.json({ ok: false, code: "bad_gear", error: "unknown brewer key" });
const grinders = [
...new Set(
rawGrinders
.filter((g) => typeof g === "string")
.map((g) => g.trim().slice(0, 100))
.filter(Boolean),
),
].slice(0, 20);
const row = (
await db.query(
`INSERT INTO user_gear(user_id,brewers,grinders,updated_at) VALUES($1,$2,$3,now())
ON CONFLICT (user_id) DO UPDATE SET brewers=$2, grinders=$3, updated_at=now()
RETURNING brewers,grinders`,
[req.user.id, JSON.stringify(brewers), JSON.stringify(grinders)],
)
).rows[0];
res.json({ ok: true, gear: { brewers: row.brewers, grinders: row.grinders } });
} catch (e) {
next(e);
}
});
app.get("/api/brew-methods", requireAuth, (_req, res) =>
res.json({
ok: true,
+4
View File
@@ -146,6 +146,10 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
put: { tags: ["beans"], summary: "Update a bean", parameters: [idParam], requestBody: jsonBody(beanBody), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["beans"], summary: "Delete a bean", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/gear": {
get: { tags: ["brews"], summary: "Owned equipment (brewers + grinders)", responses: { 200: ok("Gear", obj({ ok: bool, gear: obj({ brewers: arr(str), grinders: arr(str) }) })) } },
put: { tags: ["brews"], summary: "Set owned equipment", requestBody: jsonBody(obj({ brewers: arr({ ...str, description: "brew-method keys" }), grinders: arr(str) }, ["brewers", "grinders"])), responses: { 200: ok("Saved"), 400: err("Unknown brewer key") } },
},
"/api/brew-methods": { get: { tags: ["brews"], summary: "Brew-method taxonomy (categories + methods)", responses: { 200: ok("Methods", obj({ ok: bool, categories: arr(obj({ key: str, name: str })), methods: arr(obj({ key: str, name: str, category: str })) })) } } },
// ── Brews ──
+46
View File
@@ -377,6 +377,52 @@ test("roaster profile aggregates uploaded roasts; plan chat is grounded in it",
assert.equal(chatCalls.length, 1);
});
test("gear: owned brewers/grinders round-trip, validate, and stay private", async () => {
const { app, agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
// Defaults to empty
const empty = await agent.get("/api/gear");
assert.equal(empty.status, 200);
assert.deepEqual(empty.body.gear, { brewers: [], grinders: [] });
// Save + read back (upsert twice to cover the ON CONFLICT path)
const first = await agent
.put("/api/gear")
.set("x-csrf-token", csrf)
.send({ brewers: ["v60", "aeropress"], grinders: ["Comandante C40"] });
assert.equal(first.status, 200);
const second = await agent
.put("/api/gear")
.set("x-csrf-token", csrf)
.send({ brewers: ["v60", "hario-switch", "v60"], grinders: ["Comandante C40", "DF64", " "] });
assert.equal(second.status, 200);
assert.deepEqual(second.body.gear.brewers, ["v60", "hario-switch"]); // deduped
assert.deepEqual(second.body.gear.grinders, ["Comandante C40", "DF64"]); // blank dropped
assert.deepEqual((await agent.get("/api/gear")).body.gear.brewers, ["v60", "hario-switch"]);
// Unknown brewer keys and non-array bodies are rejected
assert.equal(
(
await agent
.put("/api/gear")
.set("x-csrf-token", csrf)
.send({ brewers: ["teapot"], grinders: [] })
).status,
400,
);
assert.equal(
(await agent.put("/api/gear").set("x-csrf-token", csrf).send({ brewers: "v60", grinders: [] })).status,
400,
);
// Private per user
const request = (await import("supertest")).default;
const stranger = request.agent(app);
await signup(stranger, "[email protected]");
assert.deepEqual((await stranger.get("/api/gear")).body.gear.brewers, []);
});
test("per-user export and openapi spec", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
+2 -1
View File
@@ -37,7 +37,8 @@ export async function setup(env = {}, appOptions = {}) {
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE roasted_beans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,roaster text NOT NULL DEFAULT '',origin text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',variety text NOT NULL DEFAULT '',roast_level text NOT NULL DEFAULT '',roast_date date,initial_weight_g numeric,url text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE brews(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,bean_id uuid REFERENCES roasted_beans(id) ON DELETE SET NULL,method text NOT NULL,dose_g numeric,water_g numeric,yield_g numeric,grinder text NOT NULL DEFAULT '',grind_setting text NOT NULL DEFAULT '',water_temp_c numeric,brew_time_s integer,bloom_time_s integer,rating numeric,recipe text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',brewed_at timestamptz DEFAULT now(),created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE api_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),last_used_at timestamptz)`,
CREATE TABLE api_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),last_used_at timestamptz);
CREATE TABLE user_gear(user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,brewers jsonb NOT NULL DEFAULT '[]',grinders jsonb NOT NULL DEFAULT '[]',updated_at timestamptz DEFAULT now())`,
);
const app = createApp({
db,