diff --git a/db/migrations/008_user_gear.sql b/db/migrations/008_user_gear.sql new file mode 100644 index 0000000..0db7512 --- /dev/null +++ b/db/migrations/008_user_gear.sql @@ -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() +); diff --git a/public/brews.html b/public/brews.html index 319f829..fc2e9b3 100644 --- a/public/brews.html +++ b/public/brews.html @@ -107,8 +107,12 @@ > Grinder + > Grind setting + + + + + Equipment — Roast Planner + + + + + + + + + ◐ + Roast Planner + + + + + « + + + + + + + Log out + + + + + + + + + + ☰ + + + + Equipment + + The brewers and grinders you own — Brews only offers these + + + + + + + + + + + Your brewers + + + + + Tap the brewers you own. With none selected, the Brews page + shows every method. + + + + + + + Your grinders + + + + Add grinder + + + + + + + + + + diff --git a/public/js/brews.js b/public/js/brews.js index 670bc7c..a3321c8 100644 --- a/public/js/brews.js +++ b/public/js/brews.js @@ -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); diff --git a/public/js/gear.js b/public/js/gear.js new file mode 100644 index 0000000..1f7fb4e --- /dev/null +++ b/public/js/gear.js @@ -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(); diff --git a/public/js/nav.js b/public/js/nav.js index 1486b8f..1334c1d 100644 --- a/public/js/nav.js +++ b/public/js/nav.js @@ -19,6 +19,7 @@ const NAV_GROUPS = [ items: [ { href: "/brews", icon: "◉", label: "Brews" }, { href: "/beans", icon: "◍", label: "Beans" }, + { href: "/gear", icon: "⚒", label: "Equipment" }, ], }, { diff --git a/server/app.js b/server/app.js index 27271ee..5e74fb3 100644 --- a/server/app.js +++ b/server/app.js @@ -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, diff --git a/server/openapi.js b/server/openapi.js index 8826ec0..3458078 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -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 ── diff --git a/test/brewing.test.js b/test/brewing.test.js index 0d48954..df17b9e 100644 --- a/test/brewing.test.js +++ b/test/brewing.test.js @@ -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, "gear@example.com"); + + // 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, "gear2@example.com"); + 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, "export@example.com"); diff --git a/test/helpers.js b/test/helpers.js index 8cabb3d..ccd2a20 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -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,
+ The brewers and grinders you own — Brews only offers these +
+ Tap the brewers you own. With none selected, the Brews page + shows every method. +