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"
|
||||
/></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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user