From 5f3a015ea2e7136da721a2cdf436d642e25c0da9 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 5 Sep 2026 15:47:44 -0400 Subject: [PATCH] Fix report pagination and rebuild saved plan navigation --- db/migrations/013_roast_observations.sql | 2 + docs/project-review-2026-09-05.md | 8 + package-lock.json | 48 ++ package.json | 1 + public/app.css | 4 + public/index.html | 367 +------------ public/js/main.js | 261 ++-------- public/js/nav.js | 4 +- public/js/plans.js | 159 ++++++ public/js/roasts.js | 625 ++++++++++++++++++----- public/plans.html | 156 ++++++ public/roasts.html | 15 + server/app.js | 42 +- server/openapi.js | 5 +- shared/report.js | 14 +- shared/roast-comparison.js | 71 +++ test/helpers.js | 2 +- test/plans-table.test.js | 91 ++++ test/report-browser.test.js | 338 ++++++++---- test/roast-comparison.test.js | 76 +++ 20 files changed, 1480 insertions(+), 809 deletions(-) create mode 100644 db/migrations/013_roast_observations.sql create mode 100644 public/js/plans.js create mode 100644 public/plans.html create mode 100644 shared/roast-comparison.js create mode 100644 test/plans-table.test.js create mode 100644 test/roast-comparison.test.js diff --git a/db/migrations/013_roast_observations.sql b/db/migrations/013_roast_observations.sql new file mode 100644 index 0000000..75a459f --- /dev/null +++ b/db/migrations/013_roast_observations.sql @@ -0,0 +1,2 @@ +ALTER TABLE actual_roasts ADD COLUMN IF NOT EXISTS observations jsonb NOT NULL DEFAULT '{}'; +CREATE INDEX IF NOT EXISTS roast_plans_user_updated_idx ON roast_plans(user_id, updated_at DESC, id DESC); diff --git a/docs/project-review-2026-09-05.md b/docs/project-review-2026-09-05.md index 94573df..d8804d3 100644 --- a/docs/project-review-2026-09-05.md +++ b/docs/project-review-2026-09-05.md @@ -36,3 +36,11 @@ No historical roast files or measured temperatures were rewritten. Legacy profil - `npx playwright install chromium && npm run test:browser`: real Chromium form, unit-toggle, preview, download and print regressions. - `npm audit --omit=dev`: production dependency advisories. - Push `master`, then verify the matching Gitea Actions run succeeds. The pipeline owns deployment; do not redeploy manually afterward. + +## Print and plan-library follow-up + +Removed the Planner's Machine Plan section and moved editable Plan vs Actual observations to individual finished roasts. Observations retain imported readings as their fallback and do not rewrite uploaded telemetry. Ownership checks and backup/restore include the new observations field. + +The Plans navigation now opens a searchable, filterable table with 25 results per page, backed by server pagination. Selecting a plan loads that exact ID; leaving through Your Plans first flushes pending edits. Finished-roast lists also have search, filters and pagination, and plan attachment supports searching the library. + +A forced page break caused a footer-only page when warning text pushed the report beyond its first page. Reports now flow naturally, with a compact print layout and grouped record headings. Chromium tests count actual PDF pages for the warning-rich report, downloaded HTML, 110% scaling and A4; additional browser cases cover switching plans without losing edits and saving finished-roast observations. diff --git a/package-lock.json b/package-lock.json index 24baac7..b9c9bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "swagger-ui-dist": "^5.32.12" }, "devDependencies": { + "pdf-lib": "^1.17.1", "pg-mem": "^3.0.14", "playwright": "^1.63.0", "supertest": "^7.2.2" @@ -2275,6 +2276,26 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3229,6 +3250,13 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3248,6 +3276,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -3798,6 +3839,13 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", diff --git a/package.json b/package.json index 4bf5552..f264b3d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "swagger-ui-dist": "^5.32.12" }, "devDependencies": { + "pdf-lib": "^1.17.1", "pg-mem": "^3.0.14", "playwright": "^1.63.0", "supertest": "^7.2.2" diff --git a/public/app.css b/public/app.css index 3f0331c..85fa846 100644 --- a/public/app.css +++ b/public/app.css @@ -3377,3 +3377,7 @@ select.f { /* Historical machine bands and pace multipliers no longer guide planning. */ #sec-machine .band-track, .ledger-pace { display:none !important; } #sec-machine .milestone-row { grid-template-columns:1fr 90px 110px; } + +.collection-filters{display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin-bottom:18px}.collection-filters .field:first-child{flex:1;min-width:220px}.collection-pagination{display:flex;gap:10px;align-items:center;margin-top:16px}.collection-pagination p{flex:1}.plan-title-link{color:var(--ember-strong);font-weight:650;text-decoration:underline;text-underline-offset:3px} + +#plans-body td:first-child { min-width:180px; } diff --git a/public/index.html b/public/index.html index 27fd5e3..c8a4fcf 100644 --- a/public/index.html +++ b/public/index.html @@ -64,8 +64,7 @@
@@ -83,9 +82,7 @@ ⚙ Tools ▾
- + ▤ Your plans @@ -259,37 +256,7 @@
- +
+
diff --git a/public/js/main.js b/public/js/main.js index 3c4ea6e..393e732 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -32,7 +32,7 @@ let draftSyncedAt = null; // The custom name of the currently loaded plan (server column, not part of the plan JSONB — // see roast_plans.name). Header display prefers this over worksheet field 0.1 when set. let currentPlanName = ""; -let plans = []; + let lastDrawerOpener = null; let deferredInstallPrompt = null; let lotPicker = null; @@ -189,62 +189,8 @@ function renderBlend() { updateBlendTotal(); } -function renderActuatorPrintRows() { - const tbody = document.getElementById("actuator-rows"); - tbody.replaceChildren(); - state.plan.actuators.forEach((_, i) => { - const tr = document.createElement("tr"); - tr.append( - document.createRange().createContextualFragment(` - - - - - - `), - ); - tbody.appendChild(tr); - }); -} +function renderActuators() {} // Older plans retain their saved schedule for report export. -function renderActuatorTimeline() { - const wrap = document.getElementById("actuator-timeline"); - wrap.replaceChildren(); - state.plan.actuators.forEach((_, i) => { - const step = document.createElement("div"); - step.className = "actuator-step"; - step.append( - document.createRange().createContextualFragment(` -
-
- - - - - - -
- `), - ); - wrap.appendChild(step); - }); - for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) { - btn.addEventListener("click", () => { - const i = Number(btn.dataset.removeActuator); - if (state.plan.actuators.length <= 1) return; - state.plan.actuators.splice(i, 1); - renderActuators(); - renderFormFromPlan(); - recompute(); - }); - } -} - -function renderActuators() { - renderActuatorPrintRows(); - renderActuatorTimeline(); - updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too -} // ---- populate every control in the form from state.plan export function renderFormFromPlan() { @@ -532,7 +478,6 @@ function syncPlan(snapshot = structuredClone(state.plan)) { `/app?plan=${encodeURIComponent(remotePlanId)}`, ); setSaveStatus("saved"); - await loadPlans(); return true; } catch { setSaveStatus("failed"); @@ -737,7 +682,6 @@ function wireDrawers() { for (const [buttonId, panelId] of [ ["nav-prefill", "panel-prefill"], ["nav-alog", "panel-alog"], - ["nav-plans", "panel-plans"], ["nav-chat", "panel-chat"], ["nav-settings", "panel-settings"], ["nav-import-export", "panel-import-export"], @@ -779,138 +723,13 @@ function wireDrawers() { return { open, closeAll }; } -// The custom `name` column wins over worksheet field 0.1 wherever a plan's title is shown — -// mirrors the server's COALESCE(NULLIF(name,''), plan->'fields'->>'0.1') fallback. -function planDisplayTitle(plan) { - return plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan"; -} -async function loadPlans() { - if (!navigator.onLine) return; - const response = await fetch("/api/plans"); - if (!response.ok) return; - plans = (await response.json()).plans || []; - const list = document.getElementById("plan-list"); - list.replaceChildren( - ...plans.map((plan) => { - const item = document.createElement("li"); - item.className = "plan-list-row"; - const button = document.createElement("button"); - button.type = "button"; - button.className = "plan-list-item"; - button.classList.toggle("active", plan.id === remotePlanId); - const title = document.createElement("strong"); - title.textContent = planDisplayTitle(plan); - const updated = document.createElement("span"); - updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`; - button.append(title, updated); - button.addEventListener("click", () => selectPlan(plan)); - - const renameBtn = document.createElement("button"); - renameBtn.type = "button"; - renameBtn.className = "icon-btn plan-list-rename"; - renameBtn.style.flexShrink = "0"; - renameBtn.style.alignSelf = "center"; - renameBtn.setAttribute("aria-label", "Rename plan"); - renameBtn.textContent = "✎"; - // A sibling of the select button, not nested inside it — stopPropagation alone - // wouldn't be enough to keep a click here from also selecting the plan otherwise. - renameBtn.addEventListener("click", async (event) => { - event.stopPropagation(); - const next = prompt("Plan name", plan.name || ""); - if (next === null) return; - try { - await api(`/api/plans/${plan.id}`, { - method: "PUT", - body: JSON.stringify({ name: next.trim() }), - }); - } catch (error) { - showToast(error.message, "fail"); - return; - } - if (plan.id === remotePlanId) { - currentPlanName = next.trim(); - renderFormFromPlan(); - renderLedger(); - } - await loadPlans(); - }); - - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - deleteBtn.className = "icon-btn plan-list-delete"; - deleteBtn.setAttribute("aria-label", `Delete ${title.textContent}`); - deleteBtn.textContent = "✕"; - // A sibling of the select button, not nested inside it — stopPropagation alone - // wouldn't be enough to keep a click here from also selecting the plan otherwise. - deleteBtn.addEventListener("click", (event) => { - event.stopPropagation(); - deletePlan(plan); - }); - - item.append(button, renameBtn, deleteBtn); - return item; - }), - ); -} -async function selectPlan(plan) { - if (!(await flushCurrentPlan())) return; - remotePlanId = plan.id; - draftSyncedAt = plan.updated_at; - currentPlanName = plan.name || ""; - state.plan = { ...blankPlan(), ...plan.plan }; - history.replaceState( - null, - "", - `/app?plan=${encodeURIComponent(remotePlanId)}`, - ); - renderBlend(); - renderActuators(); - renderFormFromPlan(); - recompute(); - // renderFormFromPlan() sets the lot + + + + + +
+ + + + + + + + + + + + + + +
PlanOriginCultivarProcessRoast levelBatchUpdatedActions
+
+
+

+ +
+ + + + + + + + diff --git a/public/roasts.html b/public/roasts.html index b086516..447f89e 100644 --- a/public/roasts.html +++ b/public/roasts.html @@ -75,6 +75,11 @@
+
@@ -90,6 +95,7 @@
+

Upload finished roasts here (they stay unattached to a plan), or from the planner's Reference curve drawer to attach them to the @@ -137,6 +143,15 @@

+
+

Plan vs Actual

+

Compare the attached plan with recorded milestones. You can add observations here; the uploaded curve and review retain the original file readings. Clear an entry to use its original reading again.

+
+
MilestonePlan timePlan BTActual timeActual BTTime differenceNotes
+

+ +
+

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

${rows}
MilestoneTimeBT (°F)What to look for
-

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

${sr}
WhenHeatFanBT (°F)Purpose
-
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

${p .map((v) => { const a = plan.planActual?.[v.key] ?? {}; @@ -134,8 +134,8 @@ export function renderReportBody(plan, name = "") { }) .join("")}
MilestonePlan time / BTActual timeActual BT (°F)Notes

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.

`; +

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.

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 `${e(name || plan.fields?.["0.1"] || "Roast plan")}${renderReportBody(plan, name)}`; diff --git a/shared/roast-comparison.js b/shared/roast-comparison.js new file mode 100644 index 0000000..bb9eb1a --- /dev/null +++ b/shared/roast-comparison.js @@ -0,0 +1,71 @@ +import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__"; +import { buildPlanCurve } from "./curve.js?v=__ASSET_VERSION__"; +import { parseDuration } from "./time.js?v=__ASSET_VERSION__"; +export const MILESTONES = [ + ["charge", "Charge"], + ["tp", "Turning point"], + ["yellow", "Yellow / dry end"], + ["fc", "First crack"], + ["drop", "Drop"], +]; +export function sanitizeObservations(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const out = {}; + for (const [key] of MILESTONES) { + if (raw[key] === undefined) continue; + const row = raw[key]; + if (!row || typeof row !== "object" || Array.isArray(row)) return null; + const clean = {}; + for (const field of ["timeS", "tempC"]) { + const v = row[field]; + if (v === undefined || v === null) { + clean[field] = null; + continue; + } + if ( + typeof v !== "number" || + !Number.isFinite(v) || + v < 0 || + v > (field === "timeS" ? 7200 : 350) + ) + return null; + clean[field] = v; + } + clean.note = String(row.note ?? "").slice(0, 2000); + out[key] = clean; + } + return out; +} +export function buildComparison(detail) { + const points = detail.plan + ? buildPlanCurve(detail.plan, computeLedger(detail.plan)) + : []; + return MILESTONES.map(([key, label]) => { + const target = points.find((p) => p.key === key); + const uploaded = + key === "tp" + ? detail.parsed?.turningPoint + : detail.parsed?.milestones?.find((p) => p.key === key); + const saved = detail.observations?.[key], + legacy = detail.plan?.planActual?.[key]; + const timeS = + saved?.timeS ?? uploaded?.timeS ?? parseDuration(legacy?.actualTime); + const legacyTemp = + legacy?.actualBt !== "" && legacy?.actualBt != null + ? Number(legacy.actualBt) + : null; + const tempC = + saved?.tempC ?? + uploaded?.tempC ?? + (Number.isFinite(legacyTemp) ? legacyTemp : null); + return { + key, + label, + target, + timeS, + tempC, + note: saved?.note ?? legacy?.note ?? "", + deltaS: target && timeS != null ? timeS - target.timeS : null, + }; + }); +} diff --git a/test/helpers.js b/test/helpers.js index e20e684..bf072e9 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -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,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 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,observations jsonb NOT NULL DEFAULT '{}',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); diff --git a/test/plans-table.test.js b/test/plans-table.test.js new file mode 100644 index 0000000..0465369 --- /dev/null +++ b/test/plans-table.test.js @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import request from "supertest"; +import { setup, signup } from "./helpers.js"; +import { blankPlan } from "../shared/fields.js"; +test("plans table page and single-plan route require authentication and enforce ownership", async () => { + const { app, agent } = await setup(); + assert.equal((await agent.get("/plans")).status, 302); + const { csrf } = await signup(agent, "owner@example.com"); + const other = request.agent(app); + await signup(other, "other@example.com"); + const made = await agent + .post("/api/plans") + .set("x-csrf-token", csrf) + .send({ name: "Owned plan", plan: blankPlan() }); + const id = made.body.plan.id; + assert.equal((await agent.get("/plans")).status, 200); + assert.equal((await agent.get("/plans.html")).status, 302); + assert.equal( + (await agent.get("/api/plans/" + id)).body.plan.name, + "Owned plan", + ); + assert.equal((await other.get("/api/plans/" + id)).status, 404); + assert.equal((await request(app).get("/api/plans/" + id)).status, 401); + assert.equal((await agent.get("/api/plans/not-a-uuid")).status, 404); +}); +test("summary search, filters and pagination exclude full telemetry and other accounts", async () => { + const { app, agent, db } = await setup(); + await signup(agent, "table@example.com"); + const other = request.agent(app); + const { csrf } = await signup(other, "private@example.com"); + await other + .post("/api/plans") + .set("x-csrf-token", csrf) + .send({ name: "Hidden Ethiopia", plan: blankPlan() }); + const owner = ( + await db.query("SELECT id FROM users WHERE email=$1", ["table@example.com"]) + ).rows[0].id; + for (let i = 0; i < 62; i++) { + const p = blankPlan(); + Object.assign(p.fields, { + 0.1: "Coffee " + i, + 1.3: i % 2 ? "Ethiopia" : "Burundi", + 3.1: i % 2 ? "natural" : "washed", + 4.1: i % 2 ? "light" : "medium", + }); + p.reference = { curve: Array(30).fill({ t: 1, bt: 180 }) }; + await db.query( + "INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3)", + [owner, p, "Plan " + String(i).padStart(2, "0")], + ); + } + const first = (await agent.get("/api/plans?view=summary")).body; + assert.equal( + (await agent.get("/api/plans?view=summary&sort=constructor")).status, + 200, + ); + assert.equal( + (await agent.get("/api/plans?view=summary&page=9007199254740991")).status, + 400, + ); + assert.equal(first.total, 62); + assert.equal(first.plans.length, 25); + assert.ok(!("plan" in first.plans[0])); + const second = (await agent.get("/api/plans?view=summary&page=2")).body; + assert.equal(second.plans.length, 25); + assert.equal( + first.plans.some((a) => second.plans.some((b) => a.id === b.id)), + false, + ); + const filtered = ( + await agent.get( + "/api/plans?view=summary&q=ethiopia&process=natural&level=light", + ) + ).body; + assert.equal(filtered.total, 31); + assert.ok(filtered.plans.every((p) => p.origin === "Ethiopia")); + assert.equal( + (await agent.get("/api/plans?view=summary&level=dark")).body.total, + 0, + ); + assert.equal((await agent.get("/api/plans?view=summary&page=0")).status, 400); + assert.equal( + (await agent.get("/api/plans?view=summary&limit=999")).status, + 400, + ); + assert.equal( + (await agent.get("/api/plans?view=summary&q=%25")).body.total, + 0, + ); +}); diff --git a/test/report-browser.test.js b/test/report-browser.test.js index 3e1b422..62533f8 100644 --- a/test/report-browser.test.js +++ b/test/report-browser.test.js @@ -3,93 +3,257 @@ import assert from "node:assert/strict"; import { once } from "node:events"; import { readFile } from "node:fs/promises"; import { setup } from "./helpers.js"; +import { blankPlan } from "../shared/fields.js"; +import { renderReportHtml } from "../shared/report.js"; + +const browserOptions = { skip: process.env.RUN_BROWSER_TESTS !== "1" }; +async function session(t) { + const { chromium } = await import("playwright"); + const { app, db } = await setup(); + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((r) => server.close(r))); + const browser = await chromium.launch({ + headless: true, + args: ["--no-sandbox"], + }); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport: { width: 1440, height: 1100 }, + }); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + const base = `http://127.0.0.1:${server.address().port}`; + const res = await page.request.post(base + "/api/auth/signup", { + data: { + email: "browser@example.com", + password: "browser regression password", + }, + }); + assert.equal(res.status(), 201); + const { csrfToken } = await res.json(); + const create = async (plan, name = "") => + ( + await ( + await page.request.post(base + "/api/plans", { + headers: { "x-csrf-token": csrfToken }, + data: { plan, name }, + }) + ).json() + ).plan; + return { page, browser, base, create, errors, db, csrfToken }; +} +function warningPlan() { + const p = blankPlan(); + Object.assign(p.fields, { + 0.1: "Burundi Kayanza Masha — Anaerobic Natural Bourbon", + 0.4: "500", + 1.1: "Bourbon", + 1.4: "8:45", + 3.1: "anaerobic", + 4.1: "light", + 4.2: "12", + 4.3: "1:38", + 3.2: "-0:30", + 1.7: "-0:05", + 5.2: "76.14", + }); + return p; +} test( - "browser: unit toggles preserve milestone inputs; HTML download and print share the report", - { skip: process.env.RUN_BROWSER_TESTS !== "1" }, - async (t) => { - const { chromium } = await import("playwright"); - const { app } = await setup(); - const server = app.listen(0, "127.0.0.1"); - await once(server, "listening"); - t.after(() => new Promise((r) => server.close(r))); - const browser = await chromium.launch({ - headless: true, - args: ["--no-sandbox"], - }); - t.after(() => browser.close()); - const page = await browser.newPage({ - viewport: { width: 1440, height: 1100 }, - }); - const errors = []; - page.on("pageerror", (e) => errors.push(e.message)); - const base = `http://127.0.0.1:${server.address().port}`; - const signup = await page.request.post(base + "/api/auth/signup", { - data: { - email: "browser@example.com", - password: "browser regression password", - }, - }); - assert.equal(signup.status(), 201); - await page.goto(base + "/app"); - for (const [k, v] of Object.entries({ - 0.1: "Burundi Kayanza Masha", - 0.4: "500", - 1.1: "Bourbon", - 1.4: "8:45", - 4.2: "12", - 4.3: "1:38", - 4.4: "1:15", - 5.2: "761.4", - "preheat.tempC": "350", - "preheat.minutes": "20", - "temps.fc.tempC": "392", - })) - await page.locator(`#plan-form input[name="${k}"]`).fill(v); - const temp = page.locator('#plan-form input[name="temps.fc.tempC"]'); - for (const [unit, value] of [ - ["C", "200"], - ["F", "392"], - ]) { - await page.evaluate((unit) => { - const input = document.querySelector( - `#temp-unit-toggle input[value="${unit}"]`, - ); - input.checked = true; - input.dispatchEvent(new Event("change", { bubbles: true })); - }, unit); - assert.equal(await temp.inputValue(), value); - assert.equal( - await page.locator('#sec-machine input[name$=".tempC"]').count(), - 6, - ); - } - await page - .getByRole("button", { name: "Preview report", exact: true }) - .click(); - await page - .frameLocator('iframe[title="Printable roast report"]') - .getByRole("heading", { name: "Milestone plan" }) - .waitFor(); - await page.getByRole("button", { name: "Close preview" }).click(); - const pending = page.waitForEvent("download"); - await page.getByRole("button", { name: "Download HTML" }).click(); - const download = await pending; - assert.match(download.suggestedFilename(), /Burundi.*\.html$/); - const html = await readFile(await download.path(), "utf8"); - assert.ok(html.includes("440 g")); - assert.ok(html.includes("350°F")); - assert.ok(html.includes("Development · 1:15")); - assert.ok(!html.includes("°C")); - await page.emulateMedia({ media: "print" }); - await page.evaluate(() => window.dispatchEvent(new Event("beforeprint"))); - assert.equal(await page.locator("#report-print").isVisible(), true); - assert.equal(await page.locator("#print-sheet").isVisible(), false); - assert.equal(await page.locator("#report-print .sheet").count(), 2); - assert.equal(await page.locator("#report-print svg").count(), 1); - await page.emulateMedia({ media: "screen" }); - await page.evaluate(() => window.dispatchEvent(new Event("afterprint"))); - await page.goto(base + "/roasters"); - assert.deepEqual(errors, []); - }, + "browser: warning-rich reports print on two actual PDF pages without a footer-only page", + browserOptions, + async (t) => { + const { PDFDocument } = await import("pdf-lib"); + const { page, base, create, errors } = await session(t); + const plan = warningPlan(); + const saved = await create(plan); + await page.goto(base + "/app?plan=" + saved.id); + await page + .getByRole("button", { name: "Preview report", exact: true }) + .waitFor(); + assert.equal(await page.locator("#sec-machine").count(), 0); + assert.equal(await page.locator("#sec-roastlog").count(), 0); + assert.equal( + await page.locator('#plan-form input[name^="planActual."]').count(), + 0, + ); + await page + .getByRole("button", { name: "Preview report", exact: true }) + .click(); + await page + .frameLocator('iframe[title="Printable roast report"]') + .getByRole("heading", { name: "Milestone plan" }) + .waitFor(); + await page.getByRole("button", { name: "Close preview" }).click(); + const downloading = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download HTML" }).click(); + const download = await downloading; + const html = await readFile(await download.path(), "utf8"); + assert.ok(html.includes("440 g")); + assert.ok(html.includes("761.4")); + assert.ok(html.includes("Development · 1:15")); + assert.ok(!html.includes("°C")); + let pdf = await page.pdf({ + preferCSSPageSize: true, + printBackground: true, + }); + assert.equal( + (await PDFDocument.load(pdf)).getPageCount(), + 2, + "in-app print must not orphan the footer", + ); + for (const [label, content, options] of [ + ["downloaded HTML", html, { preferCSSPageSize: true }], + [ + "110% print scaling", + renderReportHtml({ + ...plan, + fields: { ...plan.fields, 5.2: "761.4", 4.4: "1:15" }, + }), + { preferCSSPageSize: true, scale: 1.1 }, + ], + ["A4 paper", html, { format: "A4", preferCSSPageSize: false }], + ]) { + await page.setContent(content); + pdf = await page.pdf({ ...options, printBackground: true }); + assert.equal((await PDFDocument.load(pdf)).getPageCount(), 2, label); + } + assert.deepEqual(errors, []); + }, +); + +test( + "browser: searchable plan table opens the selected plan and preserves edits across switches", + browserOptions, + async (t) => { + const { page, base, create, errors, db } = await session(t); + const first = warningPlan(); + const a = await create(first, "Fruit plan"); + const b = await create( + { + ...blankPlan(), + fields: { + ...blankPlan().fields, + 0.1: "Espresso coffee", + 0.4: "300", + 1.4: "8:00", + 4.3: "2:00", + }, + }, + "Espresso plan", + ); + await page.goto(base + "/app?plan=" + a.id); + await page.locator('#plan-form input[name="0.4"]').fill("520"); + await page.getByRole("link", { name: "▤ Your plans", exact: true }).click(); + await page.waitForURL("**/plans*"); + await page.locator("#plans-body .plan-title-link").first().waitFor(); + await page.locator('#plan-filters input[name="q"]').fill("Espresso"); + await page + .getByRole("link", { name: "Espresso plan", exact: true }) + .click(); + await page.waitForURL("**/app?plan=" + b.id); + await page.locator('#plan-form input[name="0.1"]').waitFor(); + assert.equal( + await page.locator('#plan-form input[name="0.1"]').inputValue(), + "Espresso coffee", + ); + assert.equal( + await page.locator("#header-coffee-name").textContent(), + "Espresso plan", + ); + await page.getByRole("link", { name: "▤ Your plans", exact: true }).click(); + await page.waitForURL("**/plans*"); + await page.getByRole("link", { name: "Fruit plan", exact: true }).click(); + await page.waitForURL("**/app?plan=" + a.id); + await page.locator('#plan-form input[name="0.4"]').waitFor(); + assert.equal( + await page.locator('#plan-form input[name="0.4"]').inputValue(), + "520", + ); + const owner = ( + await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [a.id]) + ).rows[0].user_id; + for (let i = 0; i < 28; i++) + await db.query( + "INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3)", + [owner, blankPlan(), "Library " + i], + ); + await page.goto(base + "/plans"); + await page + .locator("#plans-status") + .filter({ hasText: "of 30 plans" }) + .waitFor(); + assert.equal( + await page.locator("#plans-body .plan-title-link").count(), + 25, + ); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await page.locator("#plans-status").filter({ hasText: "26–30" }).waitFor(); + assert.equal(await page.locator("#plans-body .plan-title-link").count(), 5); + assert.deepEqual(errors, []); + }, +); + +test( + "browser: Plan vs Actual is editable on finished roasts and survives reload", + browserOptions, + async (t) => { + const { page, base, create, db, errors } = await session(t); + const plan = await create(warningPlan(), "Fruit plan"); + const owner = ( + await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [plan.id]) + ).rows[0].user_id; + const parsed = { + roast: { title: "Finished fruit roast" }, + curve: [ + { t: 0, bt: 200 }, + { t: 60, bt: 90 }, + { t: 450, bt: 190 }, + { t: 525, bt: 200 }, + ], + milestones: [ + { key: "fc", label: "First crack", timeS: 450, tempC: 190 }, + { key: "drop", label: "Drop", timeS: 525, tempC: 200 }, + ], + derived: { firstCrackS: 450, dropS: 525, developmentS: 75 }, + }; + const row = ( + await db.query( + "INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,evaluation_status) VALUES($1,$2,'fruit.alog','{}',$3,'failed') RETURNING id", + [owner, plan.id, parsed], + ) + ).rows[0]; + await page.goto(base + "/roasts?roast=" + row.id); + await page + .getByRole("heading", { name: "Plan vs Actual", exact: true }) + .waitFor(); + const time = page.getByRole("textbox", { + name: "First crack actual time", + exact: true, + }); + assert.equal(await time.inputValue(), "7:30"); + await time.fill("7:40"); + await page + .getByRole("textbox", { name: "First crack notes", exact: true }) + .fill("Audible"); + await page.getByRole("button", { name: "Save observations" }).click(); + await page + .locator("#comparison-status") + .filter({ hasText: "Observations saved" }) + .waitFor(); + await page.reload(); + await page + .getByRole("textbox", { name: "First crack actual time", exact: true }) + .waitFor(); + assert.equal(await time.inputValue(), "7:40"); + assert.equal( + await page + .getByRole("textbox", { name: "First crack notes", exact: true }) + .inputValue(), + "Audible", + ); + assert.deepEqual(errors, []); + }, ); diff --git a/test/roast-comparison.test.js b/test/roast-comparison.test.js new file mode 100644 index 0000000..ffa30af --- /dev/null +++ b/test/roast-comparison.test.js @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import request from "supertest"; +import { setup, signup } from "./helpers.js"; +import { buildComparison } from "../shared/roast-comparison.js"; +test("comparison uses per-roast observations without changing planned or uploaded readings", () => { + const detail = { + plan: { + fields: { 1.4: "8:00", 4.3: "1:30" }, + planActual: { fc: { note: "Legacy note" } }, + }, + parsed: { milestones: [{ key: "fc", timeS: 450, tempC: 190 }] }, + observations: { fc: { timeS: 460, tempC: 191, note: "Heard clearly" } }, + }; + const fc = buildComparison(detail).find((r) => r.key === "fc"); + assert.equal(fc.target.timeS, 480); + assert.equal(fc.timeS, 460); + assert.equal(fc.deltaS, -20); + assert.equal(fc.note, "Heard clearly"); + assert.equal(detail.parsed.milestones[0].timeS, 450); + assert.equal( + buildComparison({ ...detail, observations: {} }).find((r) => r.key === "fc") + .note, + "Legacy note", + ); +}); +test("roast observations persist per owner and reject invalid values", async () => { + const { app, agent, db } = await setup(); + const { csrf } = await signup(agent, "roast-owner@example.com"); + const other = request.agent(app); + const o = await signup(other, "outsider@example.com"); + const owner = ( + await db.query("SELECT id FROM users WHERE email=$1", [ + "roast-owner@example.com", + ]) + ).rows[0].id; + const row = ( + await db.query( + "INSERT INTO actual_roasts(user_id,filename,original_content,parsed) VALUES($1,'sample.alog','{}',$2) RETURNING id", + [owner, { milestones: [{ key: "fc", timeS: 450, tempC: 190 }] }], + ) + ).rows[0]; + const observations = { + fc: { timeS: 460, tempC: 191, note: "Audible first crack" }, + }; + assert.equal( + ( + await agent + .put("/api/roasts/" + row.id) + .set("x-csrf-token", csrf) + .send({ observations }) + ).status, + 200, + ); + const saved = (await agent.get("/api/roasts/" + row.id)).body.roast; + assert.deepEqual(saved.observations, observations); + assert.equal(saved.parsed.milestones[0].timeS, 450); + assert.equal( + ( + await other + .put("/api/roasts/" + row.id) + .set("x-csrf-token", o.csrf) + .send({ observations: {} }) + ).status, + 404, + ); + assert.equal( + ( + await agent + .put("/api/roasts/" + row.id) + .set("x-csrf-token", csrf) + .send({ observations: { fc: { timeS: -1 } } }) + ).status, + 400, + ); +});