Add a Recipe/steps field to brew logging
Test and deploy / test-and-deploy (push) Successful in 1m16s

Free-text recipe (bloom, pours, timings) distinct from tasting notes and
next-time notes: new brews.recipe column (migration 007), form textarea,
shown under the recipe summary in brew history, carried by Brew again,
included in backups and the OpenAPI spec.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 08:02:06 -04:00
co-authored by Claude Fable 5
parent 16d5af83f7
commit f781930832
7 changed files with 40 additions and 9 deletions
+3
View File
@@ -0,0 +1,3 @@
-- Additive only. Free-text recipe/steps for a brew (bloom, pours, plunge timing...) —
-- distinct from tasting_notes (how it tasted) and notes (what to change next time).
ALTER TABLE brews ADD COLUMN recipe text NOT NULL DEFAULT '';
+9
View File
@@ -122,6 +122,15 @@
><input class="field-input" name="bloomTime" placeholder="m:ss"
/></label>
</div>
<label class="field"
><span class="field-label">Recipe / steps</span
><textarea
class="field-input"
name="recipe"
rows="3"
placeholder="e.g. 45 g bloom, swirl · 0:45 pour to 150 g · 1:30 pour to 250 g · drawdown by 2:45"
></textarea></label
>
<label class="field" style="margin-top:6px"
><span class="field-label"
>Rating <span class="rating-pill" id="rating-value"></span></span
+10
View File
@@ -185,6 +185,14 @@ function renderBrews() {
const recipeCell = document.createElement("td");
recipeCell.textContent = recipeText(brew);
if (brew.recipe) {
const steps = document.createElement("div");
steps.className = "muted";
steps.style.fontSize = "11.5px";
steps.style.maxWidth = "340px";
steps.textContent = brew.recipe;
recipeCell.append(steps);
}
if (brew.tastingNotes) {
const notes = document.createElement("div");
notes.className = "muted";
@@ -271,6 +279,7 @@ function startEdit(brew) {
form.brewTime.value = fmtTime(brew.brewTimeS) ?? "";
form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? "";
form.rating.value = brew.rating ?? 5;
form.recipe.value = brew.recipe;
form.tastingNotes.value = brew.tastingNotes;
form.notes.value = brew.notes;
updateRatio();
@@ -318,6 +327,7 @@ document.getElementById("brew-form").addEventListener("submit", async (event) =>
brewTimeS,
bloomTimeS,
rating: Number(form.rating.value),
recipe: form.recipe.value,
tastingNotes: form.tastingNotes.value,
notes: form.notes.value,
};
+9 -5
View File
@@ -1657,6 +1657,7 @@ export function createApp({
brewTimeS: row.brew_time_s,
bloomTimeS: row.bloom_time_s,
rating: row.rating == null ? null : Number(row.rating),
recipe: row.recipe,
tastingNotes: row.tasting_notes,
notes: row.notes,
brewedAt: row.brewed_at,
@@ -1698,6 +1699,7 @@ export function createApp({
for (const [field, name] of [
["grinder", "grinder"],
["grindSetting", "grind_setting"],
["recipe", "recipe"],
["tastingNotes", "tasting_notes"],
["notes", "notes"],
])
@@ -1749,8 +1751,8 @@ export function createApp({
const row = (
await db.query(
`INSERT INTO brews
(user_id,bean_id,method,dose_g,water_g,yield_g,grinder,grind_setting,water_temp_c,brew_time_s,bloom_time_s,rating,tasting_notes,notes)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
(user_id,bean_id,method,dose_g,water_g,yield_g,grinder,grind_setting,water_temp_c,brew_time_s,bloom_time_s,rating,recipe,tasting_notes,notes)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[
req.user.id,
beanId,
@@ -1764,6 +1766,7 @@ export function createApp({
v.brew_time_s,
v.bloom_time_s,
v.rating,
v.recipe,
v.tasting_notes,
v.notes,
],
@@ -1816,8 +1819,8 @@ export function createApp({
await db.query(
`UPDATE brews SET bean_id=$1, method=$2, dose_g=$3, water_g=$4, yield_g=$5,
grinder=$6, grind_setting=$7, water_temp_c=$8, brew_time_s=$9, bloom_time_s=$10,
rating=$11, tasting_notes=$12, notes=$13, updated_at=now()
WHERE id=$14 AND user_id=$15 RETURNING *`,
rating=$11, recipe=$12, tasting_notes=$13, notes=$14, updated_at=now()
WHERE id=$15 AND user_id=$16 RETURNING *`,
[
beanId,
v.method,
@@ -1830,6 +1833,7 @@ export function createApp({
v.brew_time_s,
v.bloom_time_s,
v.rating,
v.recipe,
v.tasting_notes,
v.notes,
req.params.id,
@@ -2382,7 +2386,7 @@ export function createApp({
[
"id", "user_id", "bean_id", "method", "dose_g", "water_g", "yield_g", "grinder",
"grind_setting", "water_temp_c", "brew_time_s", "bloom_time_s", "rating",
"tasting_notes", "notes", "brewed_at", "created_at", "updated_at",
"recipe", "tasting_notes", "notes", "brewed_at", "created_at", "updated_at",
],
],
["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
+5 -3
View File
@@ -44,15 +44,17 @@ const brew = obj({
id: str, beanId: { ...str, nullable: true }, beanName: { ...str, nullable: true },
method: str, doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str,
waterTempC: num, brewTimeS: { type: "integer", nullable: true },
bloomTimeS: { type: "integer", nullable: true }, rating: num, tastingNotes: str,
notes: str, brewedAt: str,
bloomTimeS: { type: "integer", nullable: true }, rating: num, recipe: str,
tastingNotes: str, notes: str, brewedAt: str,
});
const brewBody = obj({
beanId: { ...str, nullable: true },
method: { ...str, description: "One of the brew-method keys from GET /api/brew-methods" },
doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str, waterTempC: num,
brewTimeS: { type: "integer", nullable: true }, bloomTimeS: { type: "integer", nullable: true },
rating: { ...num, description: "0-10" }, tastingNotes: str, notes: str,
rating: { ...num, description: "0-10" },
recipe: { ...str, description: "Free-text steps (bloom, pours, timings)" },
tastingNotes: str, notes: str,
});
const lot = obj({
id: str, origin: str, variety: str, process: str, producer: str,
+3
View File
@@ -149,10 +149,12 @@ test("brews: validation, update, filter by bean, method taxonomy", async () => {
grinder: "Comandante",
grindSetting: "22 clicks",
rating: 8,
recipe: "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s",
tastingNotes: "sweet, cocoa, round body",
});
assert.equal(created.status, 201);
assert.equal(created.body.brew.rating, 8);
assert.equal(created.body.brew.recipe, "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s");
const updated = await agent
.put(`/api/brews/${created.body.brew.id}`)
@@ -162,6 +164,7 @@ test("brews: validation, update, filter by bean, method taxonomy", async () => {
assert.equal(updated.body.brew.rating, 6);
assert.equal(updated.body.brew.method, "aeropress"); // untouched fields survive
assert.equal(updated.body.brew.tastingNotes, "sweet, cocoa, round body");
assert.equal(updated.body.brew.recipe.startsWith("inverted"), true);
const bean = (
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "B" })
+1 -1
View File
@@ -36,7 +36,7 @@ export async function setup(env = {}, appOptions = {}) {
CREATE TABLE cupping_sessions(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,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
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,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 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)`,
);
const app = createApp({