Move After-the-Roast onto actual roasts; add Artisan-importable updated .alog download
Test and deploy / test-and-deploy (push) Successful in 56s

One system: post-roast observations (weights, colour, DTR, cup notes,
'one change next batch', disproof) now live on the uploaded roast
(actual_roasts.after, migration 011), edited on the roast detail view
with weights/DTR prefilled from the .alog itself. The planner's screen
section is removed (the print worksheet keeps its hand-fill copy), and
the lot 'last refine' suggestion reads the note from both the new home
and legacy plans, newest wins.

Every roast now downloads two ways: the untouched original .alog, and
an updated supplemental copy with the app's data written back as valid
Artisan fields (weight, beans, roastingnotes incl. the LLM review,
cuppingnotes incl. linked cupping score/flavors) — serialized as a
Python literal so Artisan's ast.literal_eval re-imports it.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 08:35:11 -04:00
co-authored by Claude Fable 5
parent 9b6ea9880e
commit 4a3d192c2c
12 changed files with 395 additions and 161 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ export async function setup(env = {}, appOptions = {}) {
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
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 roasters(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,model text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',is_default boolean NOT NULL DEFAULT false,overrides jsonb NOT NULL DEFAULT '{}',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,roaster_id uuid REFERENCES roasters(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,roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,after jsonb NOT NULL DEFAULT '{}',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);
+88
View File
@@ -206,6 +206,94 @@ test("/roasts shell page requires a session and is never served by filename", as
assert.match(page.text, /Roast history/);
});
test("after-the-roast lives on the actual roast and flows into the updated .alog", async () => {
const stub = evaluationStub();
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const plan = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { "0.1": "After test coffee" }, inventory: {} } });
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ roastPlanId: plan.body.plan.id, filename: "after.alog", content: makeAlog("After roast") });
assert.equal(up.status, 201);
const id = up.body.roast.id;
// Save observations; junk body rejected
const put = await agent.put(`/api/roasts/${id}`).set("x-csrf-token", csrf).send({
after: {
greenIn: "250",
out: "212",
colour: "62/78",
oneChange: "first crack +0:30",
cupNotes: "papery, thin",
method: "V60 1:16",
nonsense: "dropped",
},
});
assert.equal(put.status, 200);
assert.equal(put.body.after.oneChange, "first crack +0:30");
assert.equal(put.body.after.nonsense, undefined);
assert.equal(
(await agent.put(`/api/roasts/${id}`).set("x-csrf-token", csrf).send({ after: "x" })).status,
400,
);
const detail = (await agent.get(`/api/roasts/${id}`)).body.roast;
assert.equal(detail.after.colour, "62/78");
// Original download unchanged; updated download is a Python literal with the app's data
const original = await agent.get(`/api/roasts/${id}/download`);
assert.equal(original.body.toString("utf8"), makeAlog("After roast"));
const updated = await agent.get(`/api/roasts/${id}/download?variant=updated`);
assert.equal(updated.status, 200);
assert.match(updated.headers["content-disposition"], /after-updated\.alog/);
const text = updated.body.toString("utf8");
assert.match(text, /"weight": \[250, 212, "g"\]/);
assert.match(text, /One change next batch: first crack \+0:30/);
assert.match(text, /papery, thin/);
assert.match(text, /"beans": "After test coffee"/);
// Round-trips through our own Artisan parser (same tolerant path Artisan's literal_eval uses)
const { parseAlog } = await import("../server/alog.js");
const reparsed = parseAlog(text, "after-updated.alog");
assert.equal(reparsed.roast.weightInG, 250);
assert.equal(reparsed.roast.title, "After roast");
// No JSON booleans/nulls leak into the Python literal
assert.equal(/\b(true|false|null)\b/.test(text), false);
});
test("last-refine reads the one-change note from actual roasts (new home) and legacy plans", async () => {
const stub = evaluationStub();
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Kenya", initialWeightG: 1000 })
).body.lot;
const plan = (
await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { "0.1": "Lot plan" }, inventory: { lotId: lot.id } } })
).body.plan;
// Note stored on the actual roast — the new home
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ roastPlanId: plan.id, filename: "r.alog", content: makeAlog("R") });
await agent
.put(`/api/roasts/${up.body.roast.id}`)
.set("x-csrf-token", csrf)
.send({ after: { oneChange: "development +0:15" } });
const refine = await agent.get(`/api/inventory/${lot.id}/last-refine`);
assert.equal(refine.status, 200);
assert.equal(refine.body.refine.oneChange, "development +0:15");
assert.equal(refine.body.refine.planTitle, "Lot plan");
});
test("roasts are private to their owner", async () => {
const stub = evaluationStub();
const { app, agent } = await setup({}, { evaluateRoast: stub });