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
+3
View File
@@ -0,0 +1,3 @@
-- Additive only. "After the roast" moves off the plan worksheet onto the actual roast row —
-- one system: the uploaded .alog, its review, and the post-roast observations live together.
ALTER TABLE actual_roasts ADD COLUMN after jsonb NOT NULL DEFAULT '{}';
+1 -1
View File
@@ -2594,7 +2594,7 @@ body.show-fids .field[data-fid]::after {
order: 2;
margin: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.section-nav a {
-114
View File
@@ -67,7 +67,6 @@
<a href="#sec-bean">Bean</a>
<a href="#sec-machine">Machine</a>
<a href="#sec-roastlog">Log</a>
<a href="#sec-after">After</a>
</nav>
<div class="header-actions header-tools">
@@ -1173,119 +1172,6 @@
</div>
</section>
<section class="panel-card phase-later" id="sec-after">
<div class="panel-head">
<h2>After the Roast</h2>
<span class="phase-chip">after cupping</span>
</div>
<div class="panel-body">
<details class="why-panel" data-why="sec-after">
<summary>One change per batch — arithmetic, not patience</summary>
<p>
Move two variables and there are four possible
explanations for what the cup does; move three and there
are eight, and you can't separate them without more
batches than you have coffee for. This worksheet makes
single-variable experiments cheap: every number has one
owner.
</p>
<p>
Weight loss is the honest instrument — (green roasted) ÷
green × 100 rolls total time, development, and drop
temperature into one number your scale can measure. Then
match the cup to a symptom: papery or thin → first crack
+0:30. Savory, florals gone → first crack 0:30. Sharp
acidity with no fruit → development +0:15 (a DTR under 12%
confirms it). Flat and dull → development 0:15. Flat with
no clear defect → fix the RoR shape, not the times. Write
the winner into "One change next batch" — and next time
you roast this coffee, carry it into ± Refine (1.6). That
handoff is how the plan learns.
</p>
</details>
<div class="field-grid">
<label class="field"
><span class="field-label">Green in</span>
<div class="unit-input">
<input class="field-input" name="afterRoast.greenIn" /><span
class="unit"
>g</span
>
</div></label
>
<label class="field"
><span class="field-label">Out</span>
<div class="unit-input">
<input class="field-input" name="afterRoast.out" /><span
class="unit"
>g</span
>
</div></label
>
<label class="field"
><span class="field-label">Weight loss</span>
<div class="unit-input">
<input
class="field-input"
name="afterRoast.weightLossPct"
/><span class="unit">%</span>
</div></label
>
<label class="field"
><span class="field-label">Actual DTR</span>
<div class="unit-input">
<input
class="field-input"
name="afterRoast.actualDtrPct"
/><span class="unit">%</span>
</div></label
>
</div>
<div class="field-grid">
<label class="field"
><span class="field-label">Colour WB/GR</span
><input class="field-input" name="afterRoast.colour"
/></label>
<label class="field"
><span class="field-label">Rested</span>
<div class="unit-input">
<input
class="field-input"
name="afterRoast.restedDays"
/><span class="unit">days</span>
</div></label
>
<label class="field"
><span class="field-label">Brew ratio</span
><input class="field-input" name="afterRoast.brewRatio"
/></label>
<label class="field"
><span class="field-label">Method</span
><input class="field-input" name="afterRoast.method"
/></label>
</div>
<label class="field"
><span class="field-label">Cup notes</span
><input class="field-input" name="afterRoast.cupNotes"
/></label>
<div class="field-grid">
<label class="field accent"
><span class="field-label">One change next batch</span
><input class="field-input" name="afterRoast.oneChange"
/></label>
<label class="field"
><span class="field-label">What would prove it wrong</span
><input class="field-input" name="afterRoast.disproof"
/></label>
</div>
<div class="cupping-link">
<button type="button" class="ghost-btn" id="btn-open-cupping">
Open cupping session
</button>
<span class="field-note" id="cupping-link-note"></span>
</div>
</div>
</section>
</div>
<aside class="dock" aria-label="Live plan feedback">
+3 -4
View File
@@ -314,10 +314,9 @@ export const HELP_TOPICS = {
"symptom-fix": {
title: "Symptom → cause → the single number to move",
targets: [
'label[data-fid="1.6"] .field-label',
"#sec-after .field.accent .field-label",
],
// The After-the-Roast section moved to the Roasts page; this help stays reachable from
// the ± Refine field, which is the planner-side half of the feedback loop.
targets: ['label[data-fid="1.6"] .field-label'],
render() {
return frag(
p(
-6
View File
@@ -537,12 +537,6 @@ export function recompute() {
"has-data",
hasVal(state.plan.planActual, BLANK_PLAN.planActual),
);
document
.getElementById("sec-after")
?.classList.toggle(
"has-data",
hasVal(state.plan.afterRoast, BLANK_PLAN.afterRoast),
);
lotPicker?.updateConsumeRow();
autosave();
}
+46
View File
@@ -359,6 +359,9 @@ async function openDetail(id, { keepScroll = false } = {}) {
}
document.getElementById("detail-download").href =
`/api/roasts/${encodeURIComponent(id)}/download`;
document.getElementById("detail-download-updated").href =
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
renderAfterForm(detail);
renderGraph(detail);
renderStats(detail);
renderEvaluation(detail);
@@ -366,6 +369,49 @@ async function openDetail(id, { keepScroll = false } = {}) {
if (!keepScroll) card.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ---- after the roast (moved here from the plan worksheet) -------------------
const AFTER_FIELDS = [
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
];
function renderAfterForm(detail) {
const form = document.getElementById("after-form");
const after = detail.after ?? {};
// Prefill measurable values straight from the .alog so the user only types what the
// machine couldn't know (colour, cup notes, the one change).
const auto = {
greenIn: detail.parsed?.roast?.weightInG || "",
out: detail.parsed?.roast?.weightOutG || "",
weightLossPct: detail.parsed?.roast?.weightLossPct ?? "",
actualDtrPct: detail.parsed?.derived?.dtrPct ?? "",
};
for (const field of AFTER_FIELDS)
form[field].value = after[field] ?? (auto[field] === 0 ? "" : (auto[field] ?? ""));
}
document.getElementById("after-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!openId) return;
const form = event.target;
const after = {};
for (const field of AFTER_FIELDS) after[field] = form[field].value;
const button = document.getElementById("after-save");
button.disabled = true;
try {
await api(`/api/roasts/${openId}`, {
method: "PUT",
body: JSON.stringify({ after }),
});
showToast("After-roast saved — included in the updated .alog.");
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
function closeDetail() {
openId = null;
history.replaceState(null, "", "/roasts");
+89 -1
View File
@@ -103,7 +103,15 @@
<h2 id="detail-title">Roast detail</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a class="ghost-btn small" id="detail-download" href="#" download
>Download original .alog</a
>Original .alog</a
>
<a
class="ghost-btn small"
id="detail-download-updated"
href="#"
download
title="The original file with this app's data (weights, notes, cupping) written back as Artisan fields — importable into Artisan"
>Updated .alog</a
>
<button class="ghost-btn small" type="button" id="detail-reevaluate">
Re-evaluate
@@ -128,6 +136,86 @@
></svg>
</div>
<div id="detail-stats" class="field-grid" style="margin-top:10px"></div>
<div id="detail-after" style="margin-top:16px">
<h3 style="margin:0 0 4px">After the roast</h3>
<p class="field-note" style="margin-top:0">
One change per batch. Weight loss is the honest instrument —
match the cup to a symptom, write the winner into "One
change", and the planner's ± Refine picks it up next time
you roast this lot. These values also go into the updated
.alog download.
</p>
<form id="after-form">
<div class="field-grid">
<label class="field"
><span class="field-label">Green in</span>
<div class="unit-input">
<input class="field-input" name="greenIn" /><span class="unit">g</span>
</div></label
>
<label class="field"
><span class="field-label">Out</span>
<div class="unit-input">
<input class="field-input" name="out" /><span class="unit">g</span>
</div></label
>
<label class="field"
><span class="field-label">Weight loss</span>
<div class="unit-input">
<input class="field-input" name="weightLossPct" /><span class="unit">%</span>
</div></label
>
<label class="field"
><span class="field-label">Actual DTR</span>
<div class="unit-input">
<input class="field-input" name="actualDtrPct" /><span class="unit">%</span>
</div></label
>
<label class="field"
><span class="field-label">vs target</span
><input class="field-input" name="vsTarget"
/></label>
<label class="field"
><span class="field-label">Colour WB/GR</span
><input class="field-input" name="colour"
/></label>
<label class="field"
><span class="field-label">Rested</span>
<div class="unit-input">
<input class="field-input" name="restedDays" /><span class="unit">days</span>
</div></label
>
<label class="field"
><span class="field-label">Brew ratio</span
><input class="field-input" name="brewRatio"
/></label>
<label class="field"
><span class="field-label">Method</span
><input class="field-input" name="method"
/></label>
</div>
<label class="field"
><span class="field-label">Cup notes</span
><textarea class="field-input" name="cupNotes" rows="2"></textarea></label
>
<label class="field"
><span class="field-label">One change next batch</span
><input
class="field-input"
name="oneChange"
placeholder="e.g. first crack +0:30"
/></label>
<label class="field"
><span class="field-label">What would disprove it</span
><input class="field-input" name="disproof"
/></label>
<button class="primary-btn" type="submit" id="after-save">
Save after-roast
</button>
</form>
</div>
<div id="detail-evaluation" style="margin-top:14px"></div>
</div>
</section>
+32 -1
View File
@@ -77,7 +77,7 @@ export function pyLiteralToJson(text) {
return out;
}
function parseAlogRaw(content) {
export function parseAlogRaw(content) {
try {
return JSON.parse(content);
} catch {
@@ -85,6 +85,37 @@ function parseAlogRaw(content) {
}
}
/** Serializes a JS value as a Python dict/list literal — the format Artisan itself writes
* and re-reads with ast.literal_eval. JSON is NOT safe for re-import (true/false/null are
* not Python literals), so the supplemental "updated" .alog must go out in this form. */
export function jsToPyLiteral(value) {
if (value === null || value === undefined) return "None";
if (value === true) return "True";
if (value === false) return "False";
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "None";
if (typeof value === "string") {
// Python string literal with double quotes; escape backslashes, quotes, newlines.
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
}
if (Array.isArray(value)) return `[${value.map(jsToPyLiteral).join(", ")}]`;
if (typeof value === "object")
return `{${Object.entries(value)
.map(([k, v]) => `${jsToPyLiteral(String(k))}: ${jsToPyLiteral(v)}`)
.join(", ")}}`;
return "None";
}
/** The supplemental .alog: the original file re-serialized with `updates` merged in (only
* keys whose value is not undefined are touched — everything else is preserved verbatim
* from the parse). Returns a Python-literal string Artisan can import. */
export function buildUpdatedAlog(originalContent, updates) {
const data = parseAlogRaw(originalContent);
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) data[key] = value;
}
return jsToPyLiteral(data);
}
/**
* @param {string} content raw file text
* @param {string} filename for the title fallback
+118 -19
View File
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { fetchPageText } from "./fetch-page.js";
import { runPrefill } from "./prefill.js";
import { parseAlog } from "./alog.js";
import { buildUpdatedAlog, parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { evaluateRoast as defaultEvaluateRoast } from "./evaluate-roast.js";
import { listAvailableModels as defaultListModels } from "./llm.js";
@@ -974,14 +974,25 @@ export function createApp({
)
).rows[0];
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
// Two sources, newest wins: legacy plans that stored the note in the worksheet's
// afterRoast, and actual roasts (where after-the-roast now lives) linked to a
// plan against this lot.
const row = (
await db.query(
`SELECT plan->'afterRoast'->>'oneChange' AS one_change,
`SELECT one_change, plan_title, updated_at FROM (
SELECT plan->'afterRoast'->>'oneChange' AS one_change,
plan->'fields'->>'0.1' AS plan_title, updated_at
FROM roast_plans
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
ORDER BY updated_at DESC LIMIT 1`,
UNION ALL
SELECT a.after->>'oneChange' AS one_change,
p.plan->'fields'->>'0.1' AS plan_title, a.updated_at
FROM actual_roasts a
JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.user_id=$1 AND p.plan->'inventory'->>'lotId'=$2
AND coalesce(a.after->>'oneChange','')<>''
) src ORDER BY updated_at DESC LIMIT 1`,
[req.user.id, req.params.id],
)
).rows[0];
@@ -1471,8 +1482,25 @@ export function createApp({
evaluationSummary: evaluation?.summary ?? null,
createdAt: row.created_at,
};
return full ? { ...base, parsed, evaluation, plan: row.plan ?? null } : base;
return full
? { ...base, parsed, evaluation, after: row.after ?? {}, plan: row.plan ?? null }
: base;
};
// "After the roast" observations recorded against an actual roast (moved here from the
// plan worksheet). All free-text/short strings — the numbers among them (weights, DTR)
// stay strings like the worksheet always stored them.
const AFTER_FIELDS = [
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
];
function sanitizeAfter(raw) {
if (!raw || typeof raw !== "object") return null;
const out = {};
for (const field of AFTER_FIELDS)
if (raw[field] !== undefined && raw[field] !== null)
out[field] = String(raw[field]).slice(0, 2_000);
return out;
}
// Fire-and-forget: the upload response never waits on the model (a deep review takes tens of
// seconds, and uploads arrive in batches); the row starts 'pending' and the client polls.
// Failure is recorded on the row rather than lost — 'failed' + evaluation_error, and the
@@ -1625,25 +1653,76 @@ export function createApp({
try {
const row = (
await db.query(
"SELECT filename,original_content FROM actual_roasts WHERE id=$1 AND user_id=$2",
`SELECT a.filename, a.original_content, a.after, a.evaluation, a.parsed,
p.plan->'fields'->>'0.1' AS plan_title, a.roast_plan_id
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
if (!/\.alog$/i.test(name)) name += ".alog";
name = name.replace(/\.alog$/i, "");
const updated = req.query.variant === "updated";
let content = row.original_content;
if (updated) {
// Supplemental copy: everything this app tracks about the roast written back
// as valid Artisan fields (Python-literal serialization — Artisan re-imports
// it with ast.literal_eval). The original stays untouched.
const after = row.after ?? {};
const num = (v) => {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
};
const greenIn = num(after.greenIn) ?? row.parsed?.roast?.weightInG ?? null;
const out = num(after.out) ?? row.parsed?.roast?.weightOutG ?? null;
const cupping = row.roast_plan_id
? (
await db.query(
"SELECT data,total_score FROM cupping_sessions WHERE roast_plan_id=$1 AND user_id=$2 ORDER BY updated_at DESC LIMIT 1",
[row.roast_plan_id, req.user.id],
)
).rows[0]
: null;
const cuppingParts = [
after.cupNotes,
cupping ? `Cupping score ${Number(cupping.total_score).toFixed(2)}` : null,
cupping?.data?.flavor_tags?.length
? `Flavors: ${cupping.data.flavor_tags.join(", ")}`
: null,
after.brewRatio || after.method
? `Brewed ${[after.method, after.brewRatio].filter(Boolean).join(" ")}`
: null,
after.restedDays ? `Rested ${after.restedDays} days` : null,
].filter(Boolean);
const roastingParts = [
after.oneChange ? `One change next batch: ${after.oneChange}` : null,
after.disproof ? `Would disprove it: ${after.disproof}` : null,
after.colour ? `Colour ${after.colour}` : null,
after.actualDtrPct ? `Actual DTR ${after.actualDtrPct}%` : null,
row.evaluation?.summary ? `LLM review: ${row.evaluation.summary}` : null,
].filter(Boolean);
content = buildUpdatedAlog(row.original_content, {
weight:
greenIn !== null && out !== null ? [greenIn, out, "g"] : undefined,
beans: row.plan_title || undefined,
cuppingnotes: cuppingParts.length ? cuppingParts.join("\n") : undefined,
roastingnotes: roastingParts.length ? roastingParts.join("\n") : undefined,
});
}
res.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${name}"`,
"Content-Disposition": `attachment; filename="${name}${updated ? "-updated" : ""}.alog"`,
});
res.send(row.original_content);
res.send(content);
} catch (e) {
next(e);
}
},
);
// Reassign a roast to another of the user's machines (the only mutable field on an upload —
// the file and its parse are immutable history).
// Mutable parts of an upload: which machine it belongs to, and the after-the-roast
// observations. The file and its parse are immutable history.
app.put(
"/api/roasts/:id",
requireAuth,
@@ -1651,27 +1730,47 @@ export function createApp({
requireUuidParam("id"),
async (req, res, next) => {
try {
const sets = [];
const params = [];
if (req.body.roasterId !== undefined) {
let roasterId = null;
if (req.body.roasterId !== undefined && req.body.roasterId !== null && req.body.roasterId !== "") {
if (req.body.roasterId !== null && req.body.roasterId !== "") {
if (!UUID_RE.test(req.body.roasterId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [
req.body.roasterId,
req.user.id,
])
await db.query(
"SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2",
[req.body.roasterId, req.user.id],
)
).rowCount;
if (!owns)
return res.status(404).json({ ok: false, code: "not_found" });
roasterId = req.body.roasterId;
}
params.push(roasterId);
sets.push(`roaster_id=$${params.length}`);
}
if (req.body.after !== undefined) {
const after = sanitizeAfter(req.body.after);
if (!after)
return res.status(400).json({ ok: false, code: "bad_after" });
params.push(after);
sets.push(`after=$${params.length}`);
}
if (!sets.length)
return res.status(400).json({ ok: false, code: "bad_request" });
params.push(req.params.id, req.user.id);
const r = await db.query(
"UPDATE actual_roasts SET roaster_id=$1, updated_at=now() WHERE id=$2 AND user_id=$3",
[roasterId, req.params.id, req.user.id],
`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, after`,
params,
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, roasterId });
res.json({
ok: true,
roasterId: r.rows[0].roaster_id,
after: r.rows[0].after,
});
} catch (e) {
next(e);
}
@@ -2703,7 +2802,7 @@ export function createApp({
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
"evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
"after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
[
+2 -2
View File
@@ -108,10 +108,10 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
},
"/api/roasts/{id}": {
get: { tags: ["actual roasts"], summary: "Roast detail incl. curve, LLM review, linked plan", parameters: [idParam], responses: { 200: ok("Detail"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Reassign the roast to another of your machines", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true } })), responses: { 200: ok("Reassigned"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Update the roast's machine and/or after-the-roast observations", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true }, after: { type: "object", description: "greenIn/out/weightLossPct/vsTarget/actualDtrPct/colour/restedDays/brewRatio/method/cupNotes/oneChange/disproof (strings)" } })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["actual roasts"], summary: "Delete an uploaded roast", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the original .alog", parameters: [idParam], responses: { 200: { description: "Original file as attachment" }, 404: err("Not found") } } },
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the .alog — original, or ?variant=updated for a supplemental copy with the app's data (weights, notes, cupping) written back as Artisan fields, importable into Artisan", parameters: [idParam, { name: "variant", in: "query", schema: { type: "string", enum: ["updated"] } }], responses: { 200: { description: "File as attachment" }, 404: err("Not found") } } },
"/api/roasts/{id}/evaluate": { post: { tags: ["actual roasts"], summary: "Queue a re-review", parameters: [idParam], responses: { 200: ok("Queued"), 404: err("Not found") } } },
// ── Green inventory ──
+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 });