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
+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,