Add a Recipe/steps field to brew logging
Test and deploy / test-and-deploy (push) Successful in 1m16s
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:
co-authored by
Claude Fable 5
parent
16d5af83f7
commit
f781930832
@@ -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 '';
|
||||||
@@ -122,6 +122,15 @@
|
|||||||
><input class="field-input" name="bloomTime" placeholder="m:ss"
|
><input class="field-input" name="bloomTime" placeholder="m:ss"
|
||||||
/></label>
|
/></label>
|
||||||
</div>
|
</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"
|
<label class="field" style="margin-top:6px"
|
||||||
><span class="field-label"
|
><span class="field-label"
|
||||||
>Rating <span class="rating-pill" id="rating-value">–</span></span
|
>Rating <span class="rating-pill" id="rating-value">–</span></span
|
||||||
|
|||||||
@@ -185,6 +185,14 @@ function renderBrews() {
|
|||||||
|
|
||||||
const recipeCell = document.createElement("td");
|
const recipeCell = document.createElement("td");
|
||||||
recipeCell.textContent = recipeText(brew);
|
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) {
|
if (brew.tastingNotes) {
|
||||||
const notes = document.createElement("div");
|
const notes = document.createElement("div");
|
||||||
notes.className = "muted";
|
notes.className = "muted";
|
||||||
@@ -271,6 +279,7 @@ function startEdit(brew) {
|
|||||||
form.brewTime.value = fmtTime(brew.brewTimeS) ?? "";
|
form.brewTime.value = fmtTime(brew.brewTimeS) ?? "";
|
||||||
form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? "";
|
form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? "";
|
||||||
form.rating.value = brew.rating ?? 5;
|
form.rating.value = brew.rating ?? 5;
|
||||||
|
form.recipe.value = brew.recipe;
|
||||||
form.tastingNotes.value = brew.tastingNotes;
|
form.tastingNotes.value = brew.tastingNotes;
|
||||||
form.notes.value = brew.notes;
|
form.notes.value = brew.notes;
|
||||||
updateRatio();
|
updateRatio();
|
||||||
@@ -318,6 +327,7 @@ document.getElementById("brew-form").addEventListener("submit", async (event) =>
|
|||||||
brewTimeS,
|
brewTimeS,
|
||||||
bloomTimeS,
|
bloomTimeS,
|
||||||
rating: Number(form.rating.value),
|
rating: Number(form.rating.value),
|
||||||
|
recipe: form.recipe.value,
|
||||||
tastingNotes: form.tastingNotes.value,
|
tastingNotes: form.tastingNotes.value,
|
||||||
notes: form.notes.value,
|
notes: form.notes.value,
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-5
@@ -1657,6 +1657,7 @@ export function createApp({
|
|||||||
brewTimeS: row.brew_time_s,
|
brewTimeS: row.brew_time_s,
|
||||||
bloomTimeS: row.bloom_time_s,
|
bloomTimeS: row.bloom_time_s,
|
||||||
rating: row.rating == null ? null : Number(row.rating),
|
rating: row.rating == null ? null : Number(row.rating),
|
||||||
|
recipe: row.recipe,
|
||||||
tastingNotes: row.tasting_notes,
|
tastingNotes: row.tasting_notes,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
brewedAt: row.brewed_at,
|
brewedAt: row.brewed_at,
|
||||||
@@ -1698,6 +1699,7 @@ export function createApp({
|
|||||||
for (const [field, name] of [
|
for (const [field, name] of [
|
||||||
["grinder", "grinder"],
|
["grinder", "grinder"],
|
||||||
["grindSetting", "grind_setting"],
|
["grindSetting", "grind_setting"],
|
||||||
|
["recipe", "recipe"],
|
||||||
["tastingNotes", "tasting_notes"],
|
["tastingNotes", "tasting_notes"],
|
||||||
["notes", "notes"],
|
["notes", "notes"],
|
||||||
])
|
])
|
||||||
@@ -1749,8 +1751,8 @@ export function createApp({
|
|||||||
const row = (
|
const row = (
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO brews
|
`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)
|
(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) RETURNING *`,
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||||
[
|
[
|
||||||
req.user.id,
|
req.user.id,
|
||||||
beanId,
|
beanId,
|
||||||
@@ -1764,6 +1766,7 @@ export function createApp({
|
|||||||
v.brew_time_s,
|
v.brew_time_s,
|
||||||
v.bloom_time_s,
|
v.bloom_time_s,
|
||||||
v.rating,
|
v.rating,
|
||||||
|
v.recipe,
|
||||||
v.tasting_notes,
|
v.tasting_notes,
|
||||||
v.notes,
|
v.notes,
|
||||||
],
|
],
|
||||||
@@ -1816,8 +1819,8 @@ export function createApp({
|
|||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE brews SET bean_id=$1, method=$2, dose_g=$3, water_g=$4, yield_g=$5,
|
`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,
|
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()
|
rating=$11, recipe=$12, tasting_notes=$13, notes=$14, updated_at=now()
|
||||||
WHERE id=$14 AND user_id=$15 RETURNING *`,
|
WHERE id=$15 AND user_id=$16 RETURNING *`,
|
||||||
[
|
[
|
||||||
beanId,
|
beanId,
|
||||||
v.method,
|
v.method,
|
||||||
@@ -1830,6 +1833,7 @@ export function createApp({
|
|||||||
v.brew_time_s,
|
v.brew_time_s,
|
||||||
v.bloom_time_s,
|
v.bloom_time_s,
|
||||||
v.rating,
|
v.rating,
|
||||||
|
v.recipe,
|
||||||
v.tasting_notes,
|
v.tasting_notes,
|
||||||
v.notes,
|
v.notes,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
@@ -2382,7 +2386,7 @@ export function createApp({
|
|||||||
[
|
[
|
||||||
"id", "user_id", "bean_id", "method", "dose_g", "water_g", "yield_g", "grinder",
|
"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",
|
"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"]],
|
["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
|
||||||
|
|||||||
+5
-3
@@ -44,15 +44,17 @@ const brew = obj({
|
|||||||
id: str, beanId: { ...str, nullable: true }, beanName: { ...str, nullable: true },
|
id: str, beanId: { ...str, nullable: true }, beanName: { ...str, nullable: true },
|
||||||
method: str, doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str,
|
method: str, doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str,
|
||||||
waterTempC: num, brewTimeS: { type: "integer", nullable: true },
|
waterTempC: num, brewTimeS: { type: "integer", nullable: true },
|
||||||
bloomTimeS: { type: "integer", nullable: true }, rating: num, tastingNotes: str,
|
bloomTimeS: { type: "integer", nullable: true }, rating: num, recipe: str,
|
||||||
notes: str, brewedAt: str,
|
tastingNotes: str, notes: str, brewedAt: str,
|
||||||
});
|
});
|
||||||
const brewBody = obj({
|
const brewBody = obj({
|
||||||
beanId: { ...str, nullable: true },
|
beanId: { ...str, nullable: true },
|
||||||
method: { ...str, description: "One of the brew-method keys from GET /api/brew-methods" },
|
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,
|
doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str, waterTempC: num,
|
||||||
brewTimeS: { type: "integer", nullable: true }, bloomTimeS: { type: "integer", nullable: true },
|
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({
|
const lot = obj({
|
||||||
id: str, origin: str, variety: str, process: str, producer: str,
|
id: str, origin: str, variety: str, process: str, producer: str,
|
||||||
|
|||||||
@@ -149,10 +149,12 @@ test("brews: validation, update, filter by bean, method taxonomy", async () => {
|
|||||||
grinder: "Comandante",
|
grinder: "Comandante",
|
||||||
grindSetting: "22 clicks",
|
grindSetting: "22 clicks",
|
||||||
rating: 8,
|
rating: 8,
|
||||||
|
recipe: "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s",
|
||||||
tastingNotes: "sweet, cocoa, round body",
|
tastingNotes: "sweet, cocoa, round body",
|
||||||
});
|
});
|
||||||
assert.equal(created.status, 201);
|
assert.equal(created.status, 201);
|
||||||
assert.equal(created.body.brew.rating, 8);
|
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
|
const updated = await agent
|
||||||
.put(`/api/brews/${created.body.brew.id}`)
|
.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.rating, 6);
|
||||||
assert.equal(updated.body.brew.method, "aeropress"); // untouched fields survive
|
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.tastingNotes, "sweet, cocoa, round body");
|
||||||
|
assert.equal(updated.body.brew.recipe.startsWith("inverted"), true);
|
||||||
|
|
||||||
const bean = (
|
const bean = (
|
||||||
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "B" })
|
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "B" })
|
||||||
|
|||||||
+1
-1
@@ -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 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 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 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)`,
|
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({
|
const app = createApp({
|
||||||
|
|||||||
Reference in New Issue
Block a user