After the roast
diff --git a/server/app.js b/server/app.js
index a19096e..86750b0 100644
--- a/server/app.js
+++ b/server/app.js
@@ -1,3 +1,4 @@
+import { sanitizeObservations } from "../shared/roast-comparison.js";
import express from "express";
import path from "node:path";
import crypto from "node:crypto";
@@ -46,6 +47,7 @@ const PUBLIC_SHELL_FILES = new Set([
"/inventory.html",
"/cupping.html",
"/roasts.html",
+ "/plans.html",
"/beans.html",
"/brews.html",
"/gear.html",
@@ -143,6 +145,7 @@ export function createApp({
req.path === "/inventory" ||
req.path === "/cupping" ||
req.path === "/roasts" ||
+ req.path === "/plans" ||
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/gear" ||
@@ -427,6 +430,7 @@ export function createApp({
}
});
for (const [route, file] of [
+ ["/plans", "plans.html"],
["/beans", "beans.html"],
["/brews", "brews.html"],
["/gear", "gear.html"],
@@ -749,6 +753,23 @@ export function createApp({
// ─── Plans ─────────────────────────────────────────────────────────────
app.get("/api/plans", requireAuth, async (req, res, next) => {
try {
+ if (req.query.view === "summary") {
+ const page = Number(req.query.page ?? 1), limit = Number(req.query.limit ?? 25);
+ if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger((page - 1) * limit) || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
+ return res.status(400).json({ok:false, code:"bad_pagination"});
+ const params = [req.user.id], where = ["user_id=$1"];
+ const add = (value, clause) => { params.push(value); where.push(clause.replaceAll("?", "$"+params.length)); };
+ const query = typeof req.query.q === "string" ? req.query.q.trim().slice(0,200) : "";
+ if (query) add("%"+query.replace(/[\\%_]/g, "\\$&")+"%", "(name ILIKE ? OR plan->'fields'->>'0.1' ILIKE ? OR plan->'fields'->>'1.1' ILIKE ? OR plan->'fields'->>'1.3' ILIKE ?)");
+ for (const [key,field] of [["process","3.1"],["level","4.1"]]) {
+ if (typeof req.query[key] === "string" && req.query[key]) add(req.query[key].slice(0,80), `plan->'fields'->>'${field}' = ?`);
+ }
+ const predicate = where.join(" AND ");
+ const total = Number((await db.query(`SELECT COUNT(*) AS total FROM roast_plans WHERE ${predicate}`, params)).rows[0].total);
+ const sort = new Map([["updated","updated_at DESC, id DESC"],["oldest","updated_at ASC, id ASC"],["name","name ASC, id ASC"]]).get(req.query.sort) ?? "updated_at DESC, id DESC";
+ const rows = (await db.query(`SELECT id,name,created_at,updated_at,plan->'fields' AS fields FROM roast_plans WHERE ${predicate} ORDER BY ${sort} LIMIT $${params.length+1} OFFSET $${params.length+2}`, [...params,limit,(page-1)*limit])).rows;
+ return res.json({ok:true, plans:rows.map(row=>({id:row.id,name:row.name,title:row.name||row.fields?.["0.1"]||"Untitled plan",coffee:row.fields?.["0.1"]||"",origin:row.fields?.["1.3"]||"",cultivar:row.fields?.["1.1"]||"",process:row.fields?.["3.1"]||"",level:row.fields?.["4.1"]||"",batchG:row.fields?.["0.4"]||"",updated_at:row.updated_at,created_at:row.created_at})),total,page,limit});
+ }
res.json({
ok: true,
plans: (
@@ -762,6 +783,13 @@ export function createApp({
next(e);
}
});
+ app.get("/api/plans/:id", requireAuth, requireUuidParam("id"), async (req,res,next)=>{
+ try {
+ const row=(await db.query("SELECT id,plan,name,created_at,updated_at FROM roast_plans WHERE id=$1 AND user_id=$2",[req.params.id,req.user.id])).rows[0];
+ if(!row) return res.status(404).json({ok:false,code:"not_found"});
+ res.json({ok:true,plan:row});
+ } catch(error) {next(error);}
+ });
app.post("/api/plans", requireAuth, csrf, async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
@@ -1497,7 +1525,7 @@ export function createApp({
createdAt: row.created_at,
};
return full
- ? { ...base, parsed, evaluation, after: row.after ?? {}, plan: row.plan ?? null }
+ ? { ...base, parsed, evaluation, observations: row.observations ?? {}, after: row.after ?? {}, plan: row.plan ?? null }
: base;
};
// "After the roast" observations recorded against an actual roast (moved here from the
@@ -1782,6 +1810,11 @@ export function createApp({
params.push(roastPlanId);
sets.push(`roast_plan_id=$${params.length}`);
}
+ if (req.body.observations !== undefined) {
+ const observations = sanitizeObservations(req.body.observations);
+ if (!observations) return res.status(400).json({ok:false,code:"bad_observations"});
+ params.push(observations);sets.push(`observations=$${params.length}`);
+ }
if (req.body.after !== undefined) {
const after = sanitizeAfter(req.body.after);
if (!after)
@@ -1793,7 +1826,7 @@ export function createApp({
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 ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after`,
+ `UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after, observations`,
params,
);
if (!r.rowCount)
@@ -1803,6 +1836,7 @@ export function createApp({
roasterId: r.rows[0].roaster_id,
roastPlanId: r.rows[0].roast_plan_id,
after: r.rows[0].after,
+ observations: r.rows[0].observations,
});
} catch (e) {
next(e);
@@ -2855,7 +2889,7 @@ export function createApp({
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
- "after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
+ "observations", "after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
[
@@ -2936,7 +2970,7 @@ export function createApp({
for (const row of rows) {
const params = columns.map((column) => {
const value = row[column];
- if (value === undefined) return null;
+ if (value === undefined) return table === "actual_roasts" && column === "observations" ? "{}" : null;
// jsonb columns arrive as objects; everything else is scalar.
return value !== null && typeof value === "object"
? JSON.stringify(value)
diff --git a/server/openapi.js b/server/openapi.js
index 97aebf6..f52870c 100644
--- a/server/openapi.js
+++ b/server/openapi.js
@@ -92,10 +92,11 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
// ── Roast plans ──
"/api/plans": {
- get: { tags: ["roast plans"], summary: "List your roast plans", responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
+ get: { tags: ["roast plans"], summary: "List your roast plans; view=summary returns paginated lightweight rows", parameters: ["view","q","process","level","sort","page","limit"].map(name=>({name,in:"query",schema:{type:"string"}})), responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } },
},
"/api/plans/{id}": {
+ get: {tags:["roast plans"],summary:"Read one of your plans",parameters:[idParam],responses:{200:ok("Plan"),404:err("Not found")}},
put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["roast plans"], summary: "Delete a roast plan", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
@@ -108,7 +109,7 @@ 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: "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") } },
+ put: { tags: ["actual roasts"], summary: "Update the roast’s plan, machine, milestone annotations or after-roast notes", parameters: [idParam], requestBody: jsonBody(obj({ roastPlanId:{...str,nullable:true}, observations:{type:"object",description:"Milestone keys charge/tp/yellow/fc/drop, each containing timeS, tempC (nullable numbers), and note"}, 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 .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") } } },
diff --git a/shared/report.js b/shared/report.js
index 7c82e7d..90ca08d 100644
--- a/shared/report.js
+++ b/shared/report.js
@@ -75,7 +75,7 @@ export const REPORT_CSS = `
.roast-report h1{font-size:25px;line-height:1.12;margin:0 0 8px}.roast-report h2{display:block;border:0;padding:0;font-size:13px;text-transform:uppercase;letter-spacing:.08em;margin:18px 0 8px}.roast-report p{margin:7px 0}.roast-report .subtitle{color:#626262;border-bottom:3px solid #161616;padding-bottom:14px;margin-bottom:16px}
.roast-report .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.roast-report .card{border:1px solid #d7d7d7;border-radius:9px;padding:11px}.roast-report .label{font-size:10px;color:#626262;text-transform:uppercase}.roast-report .value{font-size:20px;font-weight:700}.roast-report .callout{background:#f5f5f2;border-left:4px solid #7a4c2a;padding:10px 12px;margin:12px 0}
.roast-report table{width:100%;border-collapse:collapse;font-size:11px}.roast-report th{text-align:left;border-bottom:2px solid #161616;font-size:10px}.roast-report td{border-bottom:1px solid #d7d7d7}.roast-report th,.roast-report td{padding:7px 5px;vertical-align:top}.roast-report svg{display:block;width:100%;height:auto}.roast-report .chart{border:1px solid #d7d7d7;border-radius:10px;padding:6px;break-inside:avoid}.roast-report .notes{white-space:pre-wrap;min-height:45px;border-bottom:1px solid #bbb}.roast-report .footer{margin-top:16px;padding-top:8px;border-top:1px solid #ddd;font-size:10px;color:#626262}.roast-report a{color:#6c482b;overflow-wrap:anywhere}.roast-report .cards,.roast-report tr{break-inside:avoid}
-@page{size:Letter;margin:.35in}@media print{.roast-report{background:white;padding:0;font-size:10px}.roast-report .sheet{width:auto;max-width:none;margin:0;padding:.16in .2in;box-shadow:none}.roast-report .sheet+.sheet{break-before:page}.roast-report h2{margin-top:12px}.roast-report th,.roast-report td{padding:5px}.roast-report .chart{max-width:100%}.roast-report a{color:inherit;text-decoration:none}.roast-report p{margin:5px 0}.roast-report .notes{min-height:32px}.roast-report .card{padding:8px}.roast-report .value{font-size:18px}.roast-report table{font-size:10px}.roast-report .footer{margin-top:10px}.roast-report h1{font-size:23px}}
+@page{size:Letter;margin:.35in}@media print{.roast-report{background:white;padding:0;font-size:10px}.roast-report .sheet{width:auto;max-width:none;margin:0;padding:.16in .2in;box-shadow:none}.roast-report .sheet+.sheet{break-before:auto;padding-top:18px}.roast-report h2{margin-top:12px}.roast-report th,.roast-report td{padding:5px}.roast-report .chart{max-width:100%;max-height:3.1in}.roast-report .chart svg{height:auto;max-height:2.95in}.roast-report h1,.roast-report h2{break-after:avoid}.roast-report .record-intro{break-inside:avoid}.roast-report .footer{break-before:avoid}.roast-report p{orphans:3;widows:3}.roast-report a{color:inherit;text-decoration:none}.roast-report p{margin:5px 0}.roast-report .notes{min-height:40px}.roast-report .record-notes{display:grid;grid-template-columns:1fr 1fr;gap:18px;break-inside:avoid}.roast-report .card{padding:8px}.roast-report .value{font-size:18px}.roast-report table{font-size:10px}.roast-report .footer{margin-top:10px}.roast-report h1{font-size:23px}.roast-report .subtitle{padding-bottom:8px;margin-bottom:10px}.roast-report .callout{padding:8px 10px;margin:8px 0}}
`;
export function renderReportBody(plan, name = "") {
const l = computeLedger(plan),
@@ -122,11 +122,11 @@ export function renderReportBody(plan, name = "") {
Use this as a starting hypothesis. Light, fruity coffee depends on the green coffee, roast progression and cup results. Machine selection never changes this plan. * Blank temperatures use an illustrative reference curve, not measured or calibrated targets.
${warnings.length ? `
${warnings.map((w) => `
${e(w)}
`).join("")}
` : ""}
Milestone plan
| Milestone | Time | BT (°F) | What to look for |
${rows}
-
Roast curve
${renderReportCurve(p, l)}
The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.
-
Roast schedule & record
${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster
- Preheat
${card("Setpoint (°F)", number(plan.preheat?.tempC) === null ? "________" : f(number(plan.preheat.tempC)))}${card("Preheat time (min)", plan.preheat?.minutes || "________")}${card("Drum / fan (%)", (plan.preheat?.drumPct || "____") + " / " + (plan.preheat?.fanPct || "____"))}
Use your manufacturer’s guidance and a repeatable preheat routine. The charge BT plotted on page 1 is not a preheat setpoint. Record preheat separately.
+ Roast curve
${renderReportCurve(p, l)}
The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.
+
Roast schedule & record
${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster
+
Preheat
${card("Setpoint (°F)", number(plan.preheat?.tempC) === null ? "________" : f(number(plan.preheat.tempC)))}${card("Preheat time (min)", plan.preheat?.minutes || "________")}${card("Drum / fan (%)", (plan.preheat?.drumPct || "____") + " / " + (plan.preheat?.fanPct || "____"))}
Follow a repeatable preheat routine. Record setpoint separately from the charge BT shown on the curve.
Actuator schedule
| When | Heat | Fan | BT (°F) | Purpose |
${sr}
- Watch actual RoR, color and aroma. Plan heat changes ahead of the machine’s response; avoid abrupt late cuts. Record one change to evaluate in the next cup.
+ Watch RoR, color and aroma. Anticipate the machine’s response and avoid abrupt late cuts. Test one change in the next batch.
At-the-roaster record
| Milestone | Plan time / BT | Actual time | Actual BT (°F) | Notes |
${p
.map((v) => {
const a = plan.planActual?.[v.key] ?? {};
@@ -134,8 +134,8 @@ export function renderReportBody(plan, name = "") {
})
.join("")}
After the roast
${card("Roasted out", after.out ? after.out + " g" : "________ g")}${card("Weight loss", after.weightLossPct ? after.weightLossPct + "%" : "________ %")}${card("Rest before tasting", after.restedDays ? after.restedDays + " days" : "________ days")}
- Cup / brew notes
${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}
One change for the next batch
${e(after.oneChange)}
- Sources & interpretation
Sweet Maria’s: First Crack FAQ — audible event and temperature context.
Scott Rao: Coffee Roasting Fundamentals — probe readings do not transfer directly; time and DTR alone do not establish quality.
Timing begins with the app’s cultivar worksheet. The 1:15 light-development suggestion floor and illustrated curve are app planning heuristics, not universal roasting rules. Your explicit development override takes precedence.
`;
+ Cultivar timings, the 1:15 light-development suggestion floor and the illustrated curve are planning heuristics. Your explicit development override takes precedence.
`;
}
export function renderReportHtml(plan, name = "") {
return `