Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s

This commit is contained in:
snowspeeder committed 2026-09-05 15:47:44 -04:00
1 parent 824a964f9d
commit 5f3a015ea2
20 files changed
+1374 -703

No files matched your search

+2
View File
@@ -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);
+8
View File
@@ -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.
+48
View File
@@ -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",
+1
View File
@@ -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"
+4
View File
@@ -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; }
+5 -362
View File
@@ -64,8 +64,7 @@
<nav class="section-nav" id="section-nav" aria-label="Plan sections">
<a href="#sec-coffee">Plan</a>
<a href="#sec-bean">Bean</a>
<a href="#sec-machine">Machine</a>
<a href="#sec-roastlog">Log</a>
<a href="/roasts">Post-roast records</a>
</nav>
<div class="header-actions header-tools">
@@ -83,9 +82,7 @@
⚙ Tools ▾
</button>
<div class="header-tools-group" id="header-tools-group">
<button id="nav-plans" type="button" class="ghost-btn small">
▤ Plans
</button>
<a id="nav-plans" href="/plans" class="ghost-btn small">▤ Your plans</a>
<button id="nav-chat" type="button" class="ghost-btn small">
✳ Ask the LLM
</button>
@@ -259,37 +256,7 @@
</div>
</aside>
<aside
class="drawer hidden"
id="panel-plans"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="plans-title"
tabindex="-1"
>
<div class="drawer-head">
<h3 id="plans-title">Your plans</h3>
<button
class="icon-btn"
type="button"
data-close-drawer
aria-label="Close"
>
✕
</button>
</div>
<div class="drawer-body">
<button id="btn-new-plan" type="button" class="primary-btn">
New plan
</button>
<p class="drawer-result">
Choose a plan to continue. Every edit is stored on this device first
and then synced when online.
</p>
<ul class="plan-list" id="plan-list" aria-live="polite"></ul>
</div>
</aside>
<aside
class="drawer hidden"
@@ -591,7 +558,7 @@
first-crack anchors is another valid time on the same
clock. An average of two temperatures means nothing: first
crack is a property of the chemistry, not a dial you set.
</p>
</p><div class="inventory-consume hidden" id="inventory-consume"><span id="inventory-consume-label"></span><button type="button" class="ghost-btn small" id="btn-consume">Draw from lot</button></div>
<p>
If one component is 60% or more of the batch, roast the
whole batch as that coffee and let the rest ride along. If
@@ -710,6 +677,7 @@
>
</div>
</div>
<label class="field">Development target (m:ss, optional override)<input class="field-input" name="4.4" placeholder="e.g. 1:15" /></label>
<div class="field-grid">
<label class="field" data-fid="3.2"
><span class="field-label">Dev modifier</span
@@ -862,332 +830,7 @@
</div>
</section>
<section class="panel-card" id="sec-machine">
<div class="panel-head"><h2>Machine Plan</h2></div>
<div class="panel-body">
<details class="why-panel" data-why="sec-machine">
<summary>
Why the rate of rise must fall, and never touch zero
</summary>
<p>
A bean that is still gaining heat, but gaining it more
slowly, is moving through its reactions in order. Two
shapes break that. The crash: RoR falls off a cliff just
after first crack, when the exotherm ends and an oversized
heat cut lands at the same moment. The bake: RoR flattens
toward zero, the bean sits at temperature without
progressing, and the cup goes flat, papery and
sweetness-free — nothing after the drop rescues a bake.
</p>
<p>
So the rule is: decline steadily from the
post-turning-point peak, through small frequent heat cuts
rather than a few big ones, and stay ahead of the
machine’s response delay — changes take time to affect the bean, so cut <em>before</em> the
exotherm, gently. If a phase would need a rate outside the
illustrative ranges to hit its milestone, the plan is not
reachable — fix the ledger, not the roaster.
</p>
</details>
<div class="subhead">Temperatures &amp; rate-of-rise</div>
<p class="field-note">Blank fields use an illustrative curve: charge 392°F, yellow 320°F, first crack 392°F. These are not preheat settings. First crack must be heard; enter your own probe readings to replace the illustration. Drop is derived from development time and a declining planned rise.</p>
<div class="field-grid">
<label class="field">Preheat setpoint<input class="field-input" name="preheat.tempC" placeholder="°F" /></label>
<label class="field">Preheat time (min)<input class="field-input" name="preheat.minutes" /></label>
<label class="field">Preheat fan (%)<input class="field-input" name="preheat.fanPct" /></label>
<label class="field">Drum (%)<input class="field-input" name="preheat.drumPct" /></label>
</div>
<label class="field-label">Development target (m:ss, optional override)<input class="field-input" name="4.4" placeholder="e.g. 1:15" /></label>
<div class="milestone-list">
<div
class="milestone-row band-row"
data-band-lo="133"
data-band-hi="195"
>
<div class="milestone-label">Charge</div>
<output class="milestone-plan" data-out="t-charge"
>0:00</output
>
<div class="band-track">
<div class="band-range"></div>
<div class="band-marker"></div>
</div>
<div class="unit-input sm">
<input
class="field-input sm"
name="temps.charge.tempC"
placeholder="°C"
aria-label="Charge planned temperature"
/><span class="unit">°C</span>
</div>
</div>
<div
class="milestone-row band-row"
data-band-lo="77"
data-band-hi="100"
>
<div class="milestone-label">Turning point</div>
<input
class="field-input sm milestone-time"
name="temps.tp.time"
placeholder="m:ss"
aria-label="Turning point planned time"
/>
<div class="band-track">
<div class="band-range"></div>
<div class="band-marker"></div>
</div>
<div class="unit-input sm">
<input
class="field-input sm"
name="temps.tp.tempC"
placeholder="°C"
aria-label="Turning point planned temperature"
/><span class="unit">°C</span>
</div>
</div>
<div
class="milestone-row band-row"
data-band-lo="148"
data-band-hi="166"
>
<div class="milestone-label">Yellow</div>
<output class="milestone-plan" data-out="t-yellow">—</output>
<div class="band-track">
<div class="band-range"></div>
<div class="band-marker"></div>
</div>
<div class="unit-input sm">
<input
class="field-input sm"
name="temps.yellow.tempC"
placeholder="°C"
aria-label="Yellow planned temperature"
/><span class="unit">°C</span>
</div>
</div>
<div
class="milestone-row band-row"
data-band-lo="176"
data-band-hi="187"
>
<div class="milestone-label">First crack</div>
<output class="milestone-plan" data-out="t-fc">—</output>
<div class="band-track">
<div class="band-range"></div>
<div class="band-marker"></div>
</div>
<div class="unit-input sm">
<input
class="field-input sm"
name="temps.fc.tempC"
placeholder="°C"
aria-label="First crack planned temperature"
/><span class="unit">°C</span>
</div>
</div>
<div
class="milestone-row band-row"
data-band-lo="183"
data-band-hi="197"
>
<div class="milestone-label">Drop</div>
<output class="milestone-plan" data-out="t-drop">—</output>
<div class="band-track">
<div class="band-range"></div>
<div class="band-marker"></div>
</div>
<div class="unit-input sm">
<input
class="field-input sm"
name="temps.drop.tempC"
placeholder="°C"
aria-label="Drop planned temperature"
/><span class="unit">°C</span>
</div>
</div>
</div>
<div class="subhead">Actuator schedule (planned)</div>
<p class="field-note">
The timing of your first heat cut is the main first-crack
lever — later cut, earlier crack. Say why you make each
change; that's what makes this log readable next week.
</p>
<div class="actuator-timeline" id="actuator-timeline"></div>
<button
type="button"
class="ghost-btn small"
id="btn-add-actuator"
>
+ Add step
</button>
</div>
</section>
<div class="phase-divider" role="separator" aria-label="Roast day begins">
<span class="phase-divider-label">Roast day</span>
<span class="phase-divider-text"
>Everything above is decided before you charge. Fill in from
here down at the machine, then after cupping. Print takes the
plan with you.</span
>
</div>
<section class="panel-card phase-later" id="sec-roastlog">
<div class="panel-head">
<h2>Roast Log — Plan vs. Actual</h2>
<span class="phase-chip">during the roast</span>
</div>
<div class="panel-body">
<details class="why-panel" data-why="sec-roastlog">
<summary>Record what you observed, never what you planned</summary>
<p>
A planned milestone written into the log as though it
happened destroys the only evidence you have. If you never
actually heard first crack, write that down — "not heard,
called at 8:10 by smell" is a useful record. "8:00"
because that was the plan is worse than nothing, because
next batch you will trust it. The plan column stays fixed
beside your entries precisely so the gap is visible. The
gap is the data.
</p>
</details>
<div class="milestone-list">
<div class="milestone-row log-row">
<div class="milestone-label">Charge</div>
<output class="milestone-plan" data-out="pa-charge"
>0:00</output
>
<input
class="field-input sm"
name="planActual.charge.actualTime"
placeholder="0:00"
aria-label="Charge actual time"
/>
<input
class="field-input sm"
name="planActual.charge.actualBt"
placeholder="°C"
aria-label="Charge actual temperature"
/>
<input
class="field-input note"
name="planActual.charge.note"
placeholder="Note"
aria-label="Charge note"
/>
</div>
<div class="milestone-row log-row">
<div class="milestone-label">Turning point</div>
<output class="milestone-plan" data-out="pa-tp">—</output>
<input
class="field-input sm"
name="planActual.tp.actualTime"
placeholder="0:00"
aria-label="Turning point actual time"
/>
<input
class="field-input sm"
name="planActual.tp.actualBt"
placeholder="°C"
aria-label="Turning point actual temperature"
/>
<input
class="field-input note"
name="planActual.tp.note"
placeholder="Note"
aria-label="Turning point note"
/>
</div>
<div class="milestone-row log-row">
<div class="milestone-label">Yellow</div>
<output class="milestone-plan" data-out="pa-yellow">—</output>
<input
class="field-input sm"
name="planActual.yellow.actualTime"
placeholder="0:00"
aria-label="Yellow actual time"
/>
<input
class="field-input sm"
name="planActual.yellow.actualBt"
placeholder="°C"
aria-label="Yellow actual temperature"
/>
<input
class="field-input note"
name="planActual.yellow.note"
placeholder="Note"
aria-label="Yellow note"
/>
</div>
<div class="milestone-row log-row">
<div class="milestone-label">First crack</div>
<output class="milestone-plan" data-out="pa-fc">—</output>
<input
class="field-input sm"
name="planActual.fc.actualTime"
placeholder="0:00"
aria-label="First crack actual time"
/>
<input
class="field-input sm"
name="planActual.fc.actualBt"
placeholder="°C"
aria-label="First crack actual temperature"
/>
<input
class="field-input note"
name="planActual.fc.note"
placeholder="Note"
aria-label="First crack note"
/>
</div>
<div class="milestone-row log-row">
<div class="milestone-label">Drop</div>
<output class="milestone-plan" data-out="pa-drop">—</output>
<input
class="field-input sm"
name="planActual.drop.actualTime"
placeholder="0:00"
aria-label="Drop actual time"
/>
<input
class="field-input sm"
name="planActual.drop.actualBt"
placeholder="°C"
aria-label="Drop actual temperature"
/>
<input
class="field-input note"
name="planActual.drop.note"
placeholder="Note"
aria-label="Drop note"
/>
</div>
</div>
<div class="inventory-consume hidden" id="inventory-consume">
<span class="inventory-consume-label" id="inventory-consume-label"></span>
<button type="button" class="ghost-btn small" id="btn-consume">
Draw from lot
</button>
</div>
<div
style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px"
>
<button type="button" class="ghost-btn" id="btn-open-cupping">
Open cupping session
</button>
<span class="field-note" id="cupping-link-note" style="margin:0"></span>
</div>
<p class="field-note">
After the roast, upload the .alog on the
<a href="/roasts">Roasts page</a> — weights, colour, cup notes,
and "one change next batch" now live there with the LLM review.
</p>
</div>
</section>
</div>
+29 -220
View File
@@ -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(`
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`),
);
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(`
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card">
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
<label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
<label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
<button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
</div>
`),
);
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");
async function leaveForPlans(event) {
event.preventDefault();
if (!(await flushCurrentPlan())) {
showToast("Your changes are saved on this device, but sync failed. Retry before switching plans.", "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 <select>'s value, but that only sticks if the option is
// already in the DOM — re-render the picker's own option list against the newly-loaded
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
// leave the dropdown showing the previous plan's selection or a blank state.
lotPicker?.renderOptions();
lotPicker?.refreshRefineSuggestion();
document.querySelector("[data-close-drawer]")?.click();
}
// Shared by newPlan() and deletePlan() (when the deleted plan was the one on screen) — resets
// the whole app to a fresh, unsynced plan.
function resetToBlankPlan() {
remotePlanId = null;
draftSyncedAt = null;
currentPlanName = "";
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
lotPicker?.renderOptions();
lotPicker?.refreshRefineSuggestion();
}
async function newPlan() {
if (!confirm("Start a new plan? Your current plan is already saved locally."))
return;
if (!(await flushCurrentPlan())) return;
resetToBlankPlan();
// Without this, the Plans drawer stays open over the now-blank form — the reset happens but
// is invisible, so it doesn't read as "starting something new" until the user closes it themselves.
document.querySelector("[data-close-drawer]")?.click();
}
async function deletePlan(plan) {
const name = planDisplayTitle(plan);
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
} catch (error) {
showToast(error.message, "fail");
return;
}
showToast("Plan deleted.");
if (plan.id === remotePlanId) resetToBlankPlan();
await loadPlans();
location.assign("/plans");
}
// Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders
@@ -976,7 +795,8 @@ function wireToolbar() {
}
});
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
document.getElementById("nav-plans").addEventListener("click", leaveForPlans);
document.querySelector('#nav-links a[href="/plans"]')?.addEventListener("click", leaveForPlans);
document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({
cultivar: "",
@@ -997,18 +817,7 @@ function wireToolbar() {
location.replace("/");
}
});
document.getElementById("btn-add-actuator").addEventListener("click", () => {
state.plan.actuators.push({
time: "",
heatPct: "",
fanPct: "",
expectedBt: "",
why: "",
});
renderActuators();
renderFormFromPlan();
recompute();
});
}
function wirePwa() {
@@ -1075,7 +884,7 @@ function wirePwa() {
}
function wireSectionNav() {
const links = [...document.querySelectorAll(".section-nav a")];
const links = [...document.querySelectorAll('.section-nav a[href^="#"]')];
const sections = links
.map((a) => document.querySelector(a.getAttribute("href")))
.filter(Boolean);
@@ -1119,28 +928,28 @@ async function init() {
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
// synced at or after the server's own updated_at — otherwise another device's newer
// edit (or a draft from before sync tracking existed) would be silently discarded.
const draftIsCurrent =
selected &&
draftRemoteId === selected.id &&
draftSyncedAtAtLoad &&
new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
if (selected && !draftIsCurrent) {
state.plan = { ...blankPlan(), ...selected.plan };
let selected = null;
if (requestedId) {
const response = await fetch(`/api/plans/${encodeURIComponent(requestedId)}`);
if (!response.ok) {
showToast("That plan could not be opened. Returning to your plans.", "fail");
location.replace("/plans");
return;
}
selected = (await response.json()).plan;
} else if (!localDraft) {
const recent = await api("/api/plans?view=summary&limit=1");
if (recent.plans[0]) selected = (await api(`/api/plans/${recent.plans[0].id}`)).plan;
}
const draftIsCurrent = selected && draftRemoteId === selected.id && draftSyncedAtAtLoad && new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
if (selected) {
if (!draftIsCurrent) state.plan = { ...blankPlan(), ...selected.plan, fields: {...blankPlan().fields,...selected.plan.fields} };
remotePlanId = selected.id;
draftSyncedAt = selected.updated_at;
} else if (selected) {
remotePlanId = selected.id;
} else if (!localDraft && !requestedId && plans[0]) {
state.plan = { ...blankPlan(), ...plans[0].plan };
remotePlanId = plans[0].id;
draftSyncedAt = plans[0].updated_at;
currentPlanName = selected.name || "";
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// last account only until logout, allowing that account's local draft to reopen offline.
+2 -2
View File
@@ -8,7 +8,7 @@ const NAV_GROUPS = [
{
title: "Roasting",
items: [
{ href: "/app", icon: "◐", label: "Planner" },
{ href: "/plans", icon: "◐", label: "Plans" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
{ href: "/roasters", icon: "♨", label: "Machines" },
{ href: "/inventory", icon: "▥", label: "Green lots" },
@@ -56,7 +56,7 @@ export function renderNavLinks() {
if (item.hidden) a.classList.add("hidden");
if (item.id) a.id = item.id;
a.href = item.href;
if (item.href === location.pathname) a.setAttribute("aria-current", "page");
if (item.href === location.pathname || (item.href === "/plans" && location.pathname === "/app")) a.setAttribute("aria-current", "page");
const icon = document.createElement("span");
icon.className = "nav-icon";
icon.setAttribute("aria-hidden", "true");
+159
View File
@@ -0,0 +1,159 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
const form = document.getElementById("plan-filters"),
body = document.getElementById("plans-body"),
status = document.getElementById("plans-status");
const initial = new URLSearchParams(location.search);
let page = Math.max(1, Number(initial.get("page")) || 1),
generation = 0,
total = 0;
for (const input of form.elements)
if (input.name && initial.has(input.name))
input.value = initial.get(input.name);
function node(tag, text, className = "") {
const el = document.createElement(tag);
el.textContent = text;
el.className = className;
return el;
}
async function load() {
const current = ++generation;
const params = new URLSearchParams(new FormData(form));
params.set("view", "summary");
params.set("page", page);
params.set("limit", "25");
status.textContent = "Loading plans…";
try {
const data = await api("/api/plans?" + params);
if (current !== generation) return;
total = data.total;
if (page > 1 && (page - 1) * 25 >= total) {
page = Math.max(1, Math.ceil(total / 25));
return load();
}
const urlParams = new URLSearchParams(params);
urlParams.delete("view");
urlParams.delete("limit");
history.replaceState(null, "", "/plans?" + urlParams);
body.replaceChildren();
for (const plan of data.plans) {
const tr = document.createElement("tr"),
title = document.createElement("td"),
link = node("a", plan.title);
link.href = "/app?plan=" + encodeURIComponent(plan.id);
link.className = "plan-title-link";
title.append(link);
if (plan.coffee && plan.coffee !== plan.title)
title.append(node("div", plan.coffee, "muted"));
tr.append(
title,
...[
plan.origin,
plan.cultivar,
plan.process,
plan.level,
plan.batchG ? plan.batchG + " g" : "—",
new Date(plan.updated_at).toLocaleDateString(),
].map((v) => node("td", v || "—")),
);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const rename = node("button", "Rename", "ghost-btn small");
rename.type = "button";
rename.setAttribute("aria-label", "Rename " + plan.title);
rename.onclick = async () => {
const name = prompt("Plan name", plan.name || plan.title);
if (name === null) return;
try {
await api("/api/plans/" + plan.id, {
method: "PUT",
body: JSON.stringify({ name }),
});
await load();
} catch (e) {
showToast(e.message, "fail");
}
};
const remove = node("button", "Delete", "ghost-btn small");
remove.type = "button";
remove.setAttribute("aria-label", "Delete " + plan.title);
remove.onclick = async () => {
if (!confirm(`Delete “${plan.title}”?`)) return;
try {
await api("/api/plans/" + plan.id, { method: "DELETE" });
await load();
} catch (e) {
showToast(e.message, "fail");
}
};
actions.append(rename, remove);
tr.append(actions);
body.append(tr);
}
if (!data.plans.length) {
const tr = document.createElement("tr"),
td = node("td", "No plans match these filters.", "empty-state");
td.colSpan = 8;
tr.append(td);
body.append(tr);
}
status.textContent = total
? `${(page - 1) * 25 + 1}–${Math.min(page * 25, total)} of ${total} plans`
: "0 plans";
document.getElementById("plans-prev").disabled = page <= 1;
document.getElementById("plans-next").disabled = page * 25 >= total;
} catch (e) {
if (current === generation) {
status.textContent = "Could not load plans. " + e.message;
body.replaceChildren();
}
}
}
let timer;
form.addEventListener("input", () => {
clearTimeout(timer);
page = 1;
generation++;
timer = setTimeout(load, 250);
});
form.addEventListener("submit", (e) => {
e.preventDefault();
clearTimeout(timer);
page = 1;
load();
});
document.getElementById("plans-clear").onclick = () => {
form.reset();
page = 1;
load();
};
document.getElementById("plans-prev").onclick = () => {
page--;
load();
};
document.getElementById("plans-next").onclick = () => {
page++;
load();
};
document.getElementById("plans-new").onclick = async (e) => {
e.currentTarget.disabled = true;
try {
const result = await api("/api/plans", {
method: "POST",
body: JSON.stringify({ plan: blankPlan() }),
});
location.assign("/app?plan=" + result.plan.id);
} catch (error) {
showToast(error.message, "fail");
document.getElementById("plans-new").disabled = false;
}
};
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
initSideNav();
await loadNavUser();
await load();
+447 -58
View File
@@ -1,3 +1,8 @@
import { buildComparison } from "/shared/roast-comparison.js?v=__ASSET_VERSION__";
import {
parseDuration,
formatSigned,
} from "/shared/time.js?v=__ASSET_VERSION__";
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
@@ -7,16 +12,18 @@ import { buildPlanCurve } from "/shared/curve.js?v=__ASSET_VERSION__";
let roasts = [];
let openId = null;
let pollTimer = null;
let machineProfile = null;
let roasterList = [];
let planList = [];
let roastPage = 1;
const SVG_NS = "http://www.w3.org/2000/svg";
// Same display preference the planner's °C/°F toggle stores — data stays canonical °C,
// only labels convert.
const tempUnit = localStorage.getItem("roastPlannerTempUnit.v1") === "F" ? "F" : "C";
const displayTemp = (c) => (tempUnit === "F" ? Math.round((c * 9) / 5 + 32) : Math.round(c));
const tempUnit =
localStorage.getItem("roastPlannerTempUnit.v1") === "C" ? "C" : "F";
const displayTemp = (c) =>
tempUnit === "F" ? Math.round((c * 9) / 5 + 32) : Math.round(c);
const fmtTime = (s) =>
s == null
@@ -33,18 +40,43 @@ const GRADE_LABEL = {
function renderTable() {
const body = document.getElementById("roasts-body");
if (!roasts.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No finished roasts uploaded yet. Upload an Artisan .alog above, or from the planner's Reference curve drawer to attach it to a plan.</td></tr>`;
const filters = new FormData(document.getElementById("roast-filters")),
query = String(filters.get("q") || "").toLowerCase();
const filtered = roasts.filter(
(r) =>
(!query ||
[r.roast?.title, r.filename, r.planTitle, r.roasterName]
.join(" ")
.toLowerCase()
.includes(query)) &&
(!filters.get("linked") ||
Boolean(r.roastPlanId) === (filters.get("linked") === "yes")) &&
(!filters.get("grade") ||
r.evaluationGrade === filters.get("grade") ||
r.evaluationStatus === filters.get("grade")),
);
roastPage = Math.min(roastPage, Math.max(1, Math.ceil(filtered.length / 25)));
const visible = filtered.slice((roastPage - 1) * 25, roastPage * 25);
document.getElementById("roasts-count").textContent = filtered.length
? `${(roastPage - 1) * 25 + 1}–${Math.min(roastPage * 25, filtered.length)} of ${filtered.length} roasts`
: "0 roasts";
document.getElementById("roasts-prev").disabled = roastPage <= 1;
document.getElementById("roasts-next").disabled =
roastPage * 25 >= filtered.length;
if (!visible.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No roasts match. Adjust the filters or upload a finished roast.</td></tr>`;
return;
}
body.replaceChildren(
...roasts.map((roast) => {
...visible.map((roast) => {
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
if (roast.id === openId) tr.style.background = "var(--paper-2, #f4ede6)";
const roastCell = document.createElement("td");
const strong = document.createElement("strong");
const strong = document.createElement("a");
strong.href = `/roasts?roast=${roast.id}`;
strong.addEventListener("click", (event) => event.preventDefault());
strong.textContent = roast.roast?.title || roast.filename;
const sub = document.createElement("div");
sub.className = "muted";
@@ -70,7 +102,9 @@ function renderTable() {
roast.derived?.dtrPct == null ? "—" : `${roast.derived.dtrPct}%`;
const lossCell = document.createElement("td");
lossCell.textContent =
roast.roast?.weightLossPct == null ? "—" : `${roast.roast.weightLossPct}%`;
roast.roast?.weightLossPct == null
? "—"
: `${roast.roast.weightLossPct}%`;
tr.append(roastCell, planCell, fcCell, dropCell, dtrCell, lossCell);
tr.addEventListener("click", () => openDetail(roast.id));
@@ -97,7 +131,8 @@ function schedulePoll() {
if (!roasts.some((roast) => roast.evaluationStatus === "pending")) return;
pollTimer = setTimeout(async () => {
await loadRoasts();
if (openId) await openDetail(openId, { keepScroll: true });
if (openId && !document.querySelector("#detail-meta :focus"))
await openDetail(openId, { keepScroll: true });
}, 4000);
}
@@ -122,7 +157,13 @@ function renderGraph(detail) {
const curve = detail.parsed?.curve ?? [];
if (!curve.length) {
svg.append(
svgEl("text", { x: 357, y: 180, "text-anchor": "middle", fill: "#5a5048", "font-size": 14 }),
svgEl("text", {
x: 357,
y: 180,
"text-anchor": "middle",
fill: "#5a5048",
"font-size": 14,
}),
);
svg.lastChild.textContent = "No telemetry in this log.";
return;
@@ -132,7 +173,7 @@ function renderGraph(detail) {
let planPoints = [];
if (detail.plan) {
try {
const ledger = computeLedger(detail.plan, machineProfile);
const ledger = computeLedger(detail.plan);
planPoints = buildPlanCurve(detail.plan, ledger).filter(
(p) => p.timeS != null && p.tempC != null,
);
@@ -161,24 +202,63 @@ function renderGraph(detail) {
const minuteStep = tMax > 900 ? 120 : 60;
for (let t = 0; t <= tMax; t += minuteStep) {
svg.append(
svgEl("line", { x1: xFor(t), y1: y0, x2: xFor(t), y2: y1, stroke: "#e5dcd2", "stroke-width": t === 0 ? 1.2 : 0.7 }),
svgEl("line", {
x1: xFor(t),
y1: y0,
x2: xFor(t),
y2: y1,
stroke: "#e5dcd2",
"stroke-width": t === 0 ? 1.2 : 0.7,
}),
);
const label = svgEl("text", { x: xFor(t), y: y1 + 16, "text-anchor": "middle", fill: "#8a7f74", "font-size": 10 });
const label = svgEl("text", {
x: xFor(t),
y: y1 + 16,
"text-anchor": "middle",
fill: "#8a7f74",
"font-size": 10,
});
label.textContent = `${t / 60}`;
svg.append(label);
}
for (let c = tempLo; c <= tempHi; c += 20) {
svg.append(
svgEl("line", { x1: x0, y1: yFor(c), x2: x1, y2: yFor(c), stroke: "#e5dcd2", "stroke-width": c === tempLo ? 1.2 : 0.7 }),
svgEl("line", {
x1: x0,
y1: yFor(c),
x2: x1,
y2: yFor(c),
stroke: "#e5dcd2",
"stroke-width": c === tempLo ? 1.2 : 0.7,
}),
);
const label = svgEl("text", { x: x0 - 6, y: yFor(c) + 3, "text-anchor": "end", fill: "#8a7f74", "font-size": 10 });
const label = svgEl("text", {
x: x0 - 6,
y: yFor(c) + 3,
"text-anchor": "end",
fill: "#8a7f74",
"font-size": 10,
});
label.textContent = String(displayTemp(c));
svg.append(label);
}
const xTitle = svgEl("text", { x: (x0 + x1) / 2, y: y1 + 32, "text-anchor": "middle", fill: "#8a7f74", "font-size": 10.5 });
const xTitle = svgEl("text", {
x: (x0 + x1) / 2,
y: y1 + 32,
"text-anchor": "middle",
fill: "#8a7f74",
"font-size": 10.5,
});
xTitle.textContent = "MINUTES FROM CHARGE";
svg.append(xTitle);
const yTitle = svgEl("text", { x: 14, y: (y0 + y1) / 2, fill: "#8a7f74", "font-size": 10.5, transform: `rotate(-90 14 ${(y0 + y1) / 2})`, "text-anchor": "middle" });
const yTitle = svgEl("text", {
x: 14,
y: (y0 + y1) / 2,
fill: "#8a7f74",
"font-size": 10.5,
transform: `rotate(-90 14 ${(y0 + y1) / 2})`,
"text-anchor": "middle",
});
yTitle.textContent = `BEAN TEMP °${tempUnit}`;
svg.append(yTitle);
@@ -186,43 +266,99 @@ function renderGraph(detail) {
if (planPoints.length) {
const d = [...planPoints]
.sort((a, b) => a.timeS - b.timeS)
.map((p, i) => `${i === 0 ? "M" : "L"}${xFor(p.timeS).toFixed(1)},${yFor(p.tempC).toFixed(1)}`)
.map(
(p, i) =>
`${i === 0 ? "M" : "L"}${xFor(p.timeS).toFixed(1)},${yFor(p.tempC).toFixed(1)}`,
)
.join(" ");
svg.append(
svgEl("path", { d, fill: "none", stroke: "#5a5048", "stroke-width": 1.3, "stroke-dasharray": "4 3", opacity: 0.65 }),
svgEl("path", {
d,
fill: "none",
stroke: "#5a5048",
"stroke-width": 1.3,
"stroke-dasharray": "4 3",
opacity: 0.65,
}),
);
}
// Actual BT curve
const d = curve
.map((p, i) => `${i === 0 ? "M" : "L"}${xFor(p.t).toFixed(1)},${yFor(p.bt).toFixed(1)}`)
.map(
(p, i) =>
`${i === 0 ? "M" : "L"}${xFor(p.t).toFixed(1)},${yFor(p.bt).toFixed(1)}`,
)
.join(" ");
svg.append(svgEl("path", { d, fill: "none", stroke: "#A8481A", "stroke-width": 1.8 }));
svg.append(
svgEl("path", { d, fill: "none", stroke: "#A8481A", "stroke-width": 1.8 }),
);
// Milestones + turning point
const marks = [...(detail.parsed.milestones ?? [])];
if (detail.parsed.turningPoint)
marks.push({ label: "TP", timeS: detail.parsed.turningPoint.timeS, tempC: detail.parsed.turningPoint.tempC });
marks.push({
label: "TP",
timeS: detail.parsed.turningPoint.timeS,
tempC: detail.parsed.turningPoint.tempC,
});
for (const m of marks) {
if (m.tempC == null) continue;
svg.append(
svgEl("circle", { cx: xFor(m.timeS), cy: yFor(m.tempC), r: 3.4, fill: "#1a1512" }),
svgEl("circle", {
cx: xFor(m.timeS),
cy: yFor(m.tempC),
r: 3.4,
fill: "#1a1512",
}),
);
const label = svgEl("text", { x: xFor(m.timeS) + 6, y: yFor(m.tempC) - 6, fill: "#1a1512", "font-size": 10.5 });
const label = svgEl("text", {
x: xFor(m.timeS) + 6,
y: yFor(m.tempC) - 6,
fill: "#1a1512",
"font-size": 10.5,
});
label.textContent = `${m.label} ${fmtTime(m.timeS)}`;
svg.append(label);
}
// Legend
svg.append(svgEl("line", { x1: x0 + 8, y1: 348, x2: x0 + 36, y2: 348, stroke: "#A8481A", "stroke-width": 1.8 }));
const actualLabel = svgEl("text", { x: x0 + 42, y: 351, fill: "#5a5048", "font-size": 10.5 });
svg.append(
svgEl("line", {
x1: x0 + 8,
y1: 348,
x2: x0 + 36,
y2: 348,
stroke: "#A8481A",
"stroke-width": 1.8,
}),
);
const actualLabel = svgEl("text", {
x: x0 + 42,
y: 351,
fill: "#5a5048",
"font-size": 10.5,
});
actualLabel.textContent = "Actual BT";
svg.append(actualLabel);
if (planPoints.length) {
svg.append(
svgEl("line", { x1: x0 + 118, y1: 348, x2: x0 + 146, y2: 348, stroke: "#5a5048", "stroke-width": 1.3, "stroke-dasharray": "4 3" }),
svgEl("line", {
x1: x0 + 118,
y1: 348,
x2: x0 + 146,
y2: 348,
stroke: "#5a5048",
"stroke-width": 1.3,
"stroke-dasharray": "4 3",
}),
);
const planLabel = svgEl("text", { x: x0 + 152, y: 351, fill: "#5a5048", "font-size": 10.5 });
const planLabel = svgEl("text", {
x: x0 + 152,
y: 351,
fill: "#5a5048",
"font-size": 10.5,
});
planLabel.textContent = `Plan${detail.planTitle ? `: ${detail.planTitle}` : ""}`;
svg.append(planLabel);
}
@@ -246,10 +382,22 @@ function renderStats(detail) {
statBlock("Development", fmtTime(derived?.developmentS)),
statBlock("Drop", fmtTime(derived?.dropS)),
statBlock("DTR", derived?.dtrPct == null ? "—" : `${derived.dtrPct}%`),
statBlock("Drying share", derived?.dryingSharePct == null ? "—" : `${derived.dryingSharePct}%`),
statBlock("Maillard share", derived?.maillardSharePct == null ? "—" : `${derived.maillardSharePct}%`),
statBlock("Charge → out", `${roast?.weightInG ?? "—"} g → ${roast?.weightOutG ?? "—"} g`),
statBlock("Weight loss", roast?.weightLossPct == null ? "—" : `${roast.weightLossPct}%`),
statBlock(
"Drying share",
derived?.dryingSharePct == null ? "—" : `${derived.dryingSharePct}%`,
),
statBlock(
"Maillard share",
derived?.maillardSharePct == null ? "—" : `${derived.maillardSharePct}%`,
),
statBlock(
"Charge → out",
`${roast?.weightInG ?? "—"} g → ${roast?.weightOutG ?? "—"} g`,
),
statBlock(
"Weight loss",
roast?.weightLossPct == null ? "—" : `${roast.weightLossPct}%`,
),
);
}
@@ -275,7 +423,13 @@ function renderEvaluation(detail) {
heading.style.margin = "0 0 6px";
box.append(heading);
if (detail.evaluationStatus === "pending") {
box.append(el("p", { className: "field-note" }, "The LLM is reviewing this roast — this page refreshes automatically."));
box.append(
el(
"p",
{ className: "field-note" },
"The LLM is reviewing this roast — this page refreshes automatically.",
),
);
return;
}
if (detail.evaluationStatus === "failed") {
@@ -290,21 +444,37 @@ function renderEvaluation(detail) {
}
const ev = detail.evaluation;
if (!ev) {
box.append(el("p", { className: "field-note" }, "No review recorded for this roast."));
box.append(
el(
"p",
{ className: "field-note" },
"No review recorded for this roast.",
),
);
return;
}
const grade = el("p", {}, `Grade: ${GRADE_LABEL[ev.grade] ?? ev.grade}`);
grade.style.fontWeight = "600";
box.append(grade, el("p", {}, ev.summary));
if (ev.planComparison) box.append(evaluationList("Against the plan", [ev.planComparison]));
if (ev.highlights?.length) box.append(evaluationList("What went well", ev.highlights));
if (ev.planComparison)
box.append(evaluationList("Against the plan", [ev.planComparison]));
if (ev.highlights?.length)
box.append(evaluationList("What went well", ev.highlights));
if (ev.concerns?.length) box.append(evaluationList("Concerns", ev.concerns));
if (ev.suggestions?.length) box.append(evaluationList("Next batch", ev.suggestions));
if (ev.suggestions?.length)
box.append(evaluationList("Next batch", ev.suggestions));
if (detail.parsed?.warnings?.length)
box.append(evaluationList("Parser notes", detail.parsed.warnings));
}
async function openDetail(id, { keepScroll = false } = {}) {
if (
!keepScroll &&
openId &&
(comparisonDirty || afterFormDirty) &&
!confirm("Discard unsaved roast observations?")
)
return;
let detail;
try {
detail = (await api(`/api/roasts/${id}`)).roast;
@@ -343,10 +513,24 @@ async function openDetail(id, { keepScroll = false } = {}) {
...planList.map((plan) =>
Object.assign(document.createElement("option"), {
value: plan.id,
textContent: plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan",
textContent:
plan.title ||
plan.name ||
plan.plan?.fields?.["0.1"] ||
"Untitled plan",
}),
),
);
if (
detail.roastPlanId &&
!planList.some((p) => p.id === detail.roastPlanId)
)
planSelect.append(
Object.assign(document.createElement("option"), {
value: detail.roastPlanId,
textContent: detail.planTitle || "Attached plan",
}),
);
planSelect.value = detail.roastPlanId ?? "";
planSelect.addEventListener("change", async () => {
try {
@@ -361,7 +545,54 @@ async function openDetail(id, { keepScroll = false } = {}) {
showToast(error.message, "fail");
}
});
planWrap.append(planSelect);
const search = el("input", {
type: "search",
className: "field-input sm",
placeholder: "Find a plan…",
});
search.setAttribute("aria-label", "Find a plan to attach");
let searchTimer,
searchVersion = 0;
search.addEventListener("input", () => {
clearTimeout(searchTimer);
const version = ++searchVersion;
searchTimer = setTimeout(async () => {
try {
const data = await api(
"/api/plans?view=summary&limit=25&q=" +
encodeURIComponent(search.value),
);
if (version !== searchVersion || !planSelect.isConnected) return;
planSelect.replaceChildren(
Object.assign(document.createElement("option"), {
value: "",
textContent: "— none —",
}),
...data.plans.map((p) =>
Object.assign(document.createElement("option"), {
value: p.id,
textContent: p.title,
}),
),
);
if (
detail.roastPlanId &&
!data.plans.some((p) => p.id === detail.roastPlanId)
)
planSelect.append(
Object.assign(document.createElement("option"), {
value: detail.roastPlanId,
textContent: detail.planTitle || "Attached plan",
}),
);
planSelect.value = detail.roastPlanId || "";
} catch (error) {
showToast(error.message, "fail");
}
}, 250);
});
planSelect.setAttribute("aria-label", "Attached plan");
planWrap.append(search, planSelect);
meta.append(planWrap);
} else if (detail.planTitle) {
meta.append(` · Plan: ${detail.planTitle}`);
@@ -409,9 +640,20 @@ async function openDetail(id, { keepScroll = false } = {}) {
// The 4s pending-evaluation poll re-runs openDetail; never let that background refresh
// overwrite after-roast fields the user is currently editing.
const afterForm = document.getElementById("after-form");
if (!keepScroll || !(afterFormDirty || afterForm.contains(document.activeElement)))
if (
!keepScroll ||
!(afterFormDirty || afterForm.contains(document.activeElement))
)
renderAfterForm(detail);
if (!keepScroll) afterFormDirty = false;
if (
!keepScroll ||
(!comparisonDirty &&
!document
.getElementById("comparison-form")
.contains(document.activeElement))
)
renderComparison(detail);
renderGraph(detail);
renderStats(detail);
renderEvaluation(detail);
@@ -419,11 +661,120 @@ async function openDetail(id, { keepScroll = false } = {}) {
if (!keepScroll) card.scrollIntoView({ behavior: "smooth", block: "start" });
}
let comparisonDirty = false;
function renderComparison(detail) {
comparisonDirty = false;
document.getElementById("comparison-plan-note").textContent = detail.plan
? "* Illustrative plan temperature; actual readings may use a different probe reference."
: "Attach a plan above to see its targets alongside your observations.";
const rows = buildComparison(detail);
document.getElementById("comparison-body").replaceChildren(
...rows.map((row) => {
const tr = el("tr");
tr.dataset.key = row.key;
const time = el("input", {
className: "field-input sm",
name: row.key + ".time",
value: row.timeS == null ? "" : fmtTime(row.timeS),
placeholder: "m:ss",
});
time.setAttribute("aria-label", row.label + " actual time");
const temp = el("input", {
className: "field-input sm",
name: row.key + ".temp",
value: row.tempC == null ? "" : String(displayTemp(row.tempC)),
placeholder: "°" + tempUnit,
});
temp.setAttribute("aria-label", row.label + " actual temperature");
const note = el("input", {
className: "field-input",
name: row.key + ".note",
value: row.note,
});
note.setAttribute("aria-label", row.label + " notes");
const timeCell = el("td"),
tempCell = el("td"),
noteCell = el("td");
timeCell.append(time);
tempCell.append(temp);
noteCell.append(note);
tr.append(
el("td", {}, row.label),
el("td", {}, fmtTime(row.target?.timeS)),
el(
"td",
{},
row.target
? displayTemp(row.target.tempC) +
"°" +
tempUnit +
(row.target.illustrative ? "*" : "")
: "—",
),
timeCell,
tempCell,
el("td", {}, row.deltaS == null ? "—" : formatSigned(row.deltaS)),
noteCell,
);
return tr;
}),
);
document.getElementById("comparison-status").textContent = "";
}
document.getElementById("comparison-form").addEventListener("input", () => {
comparisonDirty = true;
});
document
.getElementById("comparison-form")
.addEventListener("submit", async (event) => {
event.preventDefault();
const id = openId;
if (!id) return;
const status = document.getElementById("comparison-status"),
observations = {};
for (const tr of document.querySelectorAll("#comparison-body tr")) {
const [time, temp, note] = tr.querySelectorAll("input"),
timeS = time.value.trim() ? parseDuration(time.value) : null;
const value = temp.value.trim() ? Number(temp.value) : null,
tempC =
value === null ? null : tempUnit === "F" ? (value - 32) / 1.8 : value;
if (
(time.value.trim() && (timeS === null || timeS < 0)) ||
(tempC !== null && !Number.isFinite(tempC))
) {
status.textContent = "Check actual times and temperatures.";
return;
}
observations[tr.dataset.key] = { timeS, tempC, note: note.value };
}
try {
await api(`/api/roasts/${id}`, {
method: "PUT",
body: JSON.stringify({ observations }),
});
comparisonDirty = false;
await openDetail(id, { keepScroll: true });
status.textContent = "Observations saved.";
} catch (error) {
status.textContent = error.message;
}
});
// ---- after the roast (moved here from the plan worksheet) -------------------
const AFTER_FIELDS = [
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
"greenIn",
"out",
"weightLossPct",
"vsTarget",
"actualDtrPct",
"colour",
"restedDays",
"brewRatio",
"method",
"cupNotes",
"oneChange",
"disproof",
];
// True while the after-roast form holds unsaved user edits (cleared on save/open).
@@ -444,10 +795,13 @@ function renderAfterForm(detail) {
actualDtrPct: detail.parsed?.derived?.dtrPct ?? "",
};
for (const field of AFTER_FIELDS)
form[field].value = after[field] ?? (auto[field] === 0 ? "" : (auto[field] ?? ""));
form[field].value =
after[field] ?? (auto[field] === 0 ? "" : (auto[field] ?? ""));
}
document.getElementById("after-form").addEventListener("submit", async (event) => {
document
.getElementById("after-form")
.addEventListener("submit", async (event) => {
event.preventDefault();
if (!openId) return;
const form = event.target;
@@ -470,18 +824,25 @@ document.getElementById("after-form").addEventListener("submit", async (event) =
});
// Open (or create) the cupping session for this roast's plan — same flow the planner offers.
document.getElementById("open-cupping").addEventListener("click", async (event) => {
document
.getElementById("open-cupping")
.addEventListener("click", async (event) => {
const button = event.currentTarget;
if (!openId || button.disabled) return;
const roast = roasts.find((r) => r.id === openId);
const planId = roast?.roastPlanId;
if (!planId) {
showToast("Attach a plan to this roast first — cupping sessions hang off plans.", "fail");
showToast(
"Attach a plan to this roast first — cupping sessions hang off plans.",
"fail",
);
return;
}
button.disabled = true;
try {
const existing = await api(`/api/cupping?plan=${encodeURIComponent(planId)}`).catch(() => ({ sessions: [] }));
const existing = await api(
`/api/cupping?plan=${encodeURIComponent(planId)}`,
).catch(() => ({ sessions: [] }));
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
return;
@@ -499,6 +860,14 @@ document.getElementById("open-cupping").addEventListener("click", async (event)
});
function closeDetail() {
if (
openId &&
(comparisonDirty || afterFormDirty) &&
!confirm("Discard unsaved roast observations?")
)
return;
comparisonDirty = false;
afterFormDirty = false;
openId = null;
history.replaceState(null, "", "/roasts");
document.getElementById("roast-detail-card").classList.add("hidden");
@@ -516,7 +885,10 @@ async function uploadFiles(files) {
try {
await api("/api/roasts", {
method: "POST",
body: JSON.stringify({ filename: file.name, content: await file.text() }),
body: JSON.stringify({
filename: file.name,
content: await file.text(),
}),
});
uploaded++;
} catch (error) {
@@ -530,14 +902,18 @@ async function uploadFiles(files) {
await loadRoasts();
}
document.getElementById("roast-upload").addEventListener("change", async (event) => {
document
.getElementById("roast-upload")
.addEventListener("change", async (event) => {
const files = [...(event.target.files ?? [])];
event.target.value = "";
if (files.length) await uploadFiles(files);
});
document.getElementById("detail-close").addEventListener("click", closeDetail);
document.getElementById("detail-reevaluate").addEventListener("click", async (event) => {
document
.getElementById("detail-reevaluate")
.addEventListener("click", async (event) => {
if (!openId || event.currentTarget.disabled) return;
const button = event.currentTarget;
button.disabled = true;
@@ -556,10 +932,13 @@ document.getElementById("detail-delete").addEventListener("click", async () => {
if (!openId) return;
const roast = roasts.find((r) => r.id === openId);
const name = roast?.roast?.title || roast?.filename || "this roast";
if (!confirm(`Delete "${name}" and its stored .alog? This cannot be undone.`)) return;
if (!confirm(`Delete "${name}" and its stored .alog? This cannot be undone.`))
return;
try {
await api(`/api/roasts/${openId}`, { method: "DELETE" });
showToast("Roast deleted.");
comparisonDirty = false;
afterFormDirty = false;
closeDetail();
await loadRoasts();
} catch (error) {
@@ -576,19 +955,13 @@ async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
// Same learned pace the planner uses, so the dashed plan overlay matches the planner's curve.
try {
machineProfile = (await api("/api/machine-profile")).profile;
} catch {
machineProfile = null;
}
try {
roasterList = (await api("/api/roasters")).roasters;
} catch {
roasterList = [];
}
try {
planList = (await api("/api/plans")).plans;
planList = (await api("/api/plans?view=summary&limit=100")).plans;
} catch {
planList = [];
}
@@ -599,3 +972,19 @@ async function init() {
}
init();
document.getElementById("roast-filters").addEventListener("input", () => {
roastPage = 1;
renderTable();
});
document
.getElementById("roast-filters")
.addEventListener("submit", (event) => event.preventDefault());
document.getElementById("roasts-prev").addEventListener("click", () => {
roastPage--;
renderTable();
});
document.getElementById("roasts-next").addEventListener("click", () => {
roastPage++;
renderTable();
});
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Plans — Roast Planner</title>
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
<link
rel="icon"
href="/icon.svg?v=__ASSET_VERSION__"
type="image/svg+xml"
/>
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<img
class="brand-mark"
src="/brand-mark.svg?v=__ASSET_VERSION__"
alt=""
aria-hidden="true"
/>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div
class="nav-user-avatar"
id="nav-user-avatar"
aria-hidden="true"
></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
☰
</button>
<div class="brand">
<div class="brand-text">
<h1>Plans</h1>
<p class="brand-sub">
Find, filter and open your saved roast plans.
</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content wide">
<section class="panel-card">
<div class="panel-head">
<h2>Your plans</h2>
<button type="button" class="primary-btn" id="plans-new">
New plan
</button>
</div>
<div class="panel-body">
<form id="plan-filters" class="collection-filters" role="search">
<label class="field"
>Search<input
class="field-input"
type="search"
name="q"
placeholder="Name, coffee, origin or cultivar"
/></label>
<label class="field"
>Process<select class="field-input" name="process">
<option value="">All processes</option>
<option value="washed">Washed</option>
<option value="natural">Natural</option>
<option value="anaerobic">Anaerobic / CM</option>
<option value="yellow-honey">Yellow / white honey</option>
<option value="red-black-honey">Red / black honey</option>
</select></label
>
<label class="field"
>Roast level<select class="field-input" name="level">
<option value="">All levels</option>
<option value="light">Light</option>
<option value="light-medium">Light-medium</option>
<option value="medium">Medium</option>
<option value="dark">Dark</option>
<option value="very-dark">Very dark</option>
</select></label
>
<label class="field"
>Sort<select class="field-input" name="sort">
<option value="updated">Recently updated</option>
<option value="oldest">Oldest updated</option>
</select></label
>
<button type="button" class="ghost-btn" id="plans-clear">
Clear filters
</button>
</form>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Plan</th>
<th>Origin</th>
<th>Cultivar</th>
<th>Process</th>
<th>Roast level</th>
<th>Batch</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="plans-body"></tbody>
</table>
</div>
<div class="collection-pagination">
<p id="plans-status" role="status"></p>
<button type="button" class="ghost-btn" id="plans-prev">
Previous</button
><button type="button" class="ghost-btn" id="plans-next">
Next
</button>
</div>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="/js/plans.js?v=__ASSET_VERSION__"></script>
</body>
</html>
+15
View File
@@ -75,6 +75,11 @@
</div>
<div class="panel-body">
<p class="field-note" id="upload-status" hidden></p>
<form class="collection-filters" id="roast-filters" role="search">
<label class="field">Search roasts<input type="search" class="field-input" name="q" placeholder="Coffee, file, machine or plan" /></label>
<label class="field">Plan<select class="field-input" name="linked"><option value="">All roasts</option><option value="yes">Attached to a plan</option><option value="no">No attached plan</option></select></label>
<label class="field">Review<select class="field-input" name="grade"><option value="">All reviews</option><option value="excellent">Excellent</option><option value="good">Good</option><option value="fair">Fair</option><option value="needs-work">Needs work</option><option value="pending">Pending</option></select></label>
</form>
<div class="table-wrap">
<table class="data-table" id="roasts-table">
<thead>
@@ -90,6 +95,7 @@
<tbody id="roasts-body"></tbody>
</table>
</div>
<div class="collection-pagination"><p id="roasts-count" role="status"></p><button type="button" class="ghost-btn" id="roasts-prev">Previous</button><button type="button" class="ghost-btn" id="roasts-next">Next</button></div>
<p class="field-note">
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 @@
</div>
<div id="detail-stats" class="field-grid" style="margin-top:10px"></div>
<section id="detail-comparison" style="margin-top:18px">
<h3>Plan vs Actual</h3>
<p class="field-note">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.</p>
<form id="comparison-form">
<div class="table-wrap"><table class="data-table"><thead><tr><th>Milestone</th><th>Plan time</th><th>Plan BT</th><th>Actual time</th><th>Actual BT</th><th>Time difference</th><th>Notes</th></tr></thead><tbody id="comparison-body"></tbody></table></div>
<p class="field-note" id="comparison-plan-note"></p>
<button class="primary-btn" type="submit">Save observations</button><span id="comparison-status" role="status"></span>
</form>
</section>
<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">
+38 -4
View File
@@ -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)
+3 -2
View File
@@ -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") } } },
+7 -7
View File
@@ -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 = "") {
<div class="callout">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.</div>
${warnings.length ? `<div class="callout">${warnings.map((w) => `<p>${e(w)}</p>`).join("")}</div>` : ""}
<h2>Milestone plan</h2><table><thead><tr><th>Milestone</th><th>Time</th><th>BT (°F)</th><th>What to look for</th></tr></thead><tbody>${rows}</tbody></table>
<h2>Roast curve</h2><div class="chart">${renderReportCurve(p, l)}</div><p>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.</p><div class="footer">Roast plan · Grams & Fahrenheit · Page 1</div></section>
<section class="sheet"><h1>Roast schedule & record</h1><div class="subtitle">${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster</div>
<h2>Preheat</h2><div class="cards">${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 || "____"))}</div><p>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.</p>
<h2>Roast curve</h2><div class="chart">${renderReportCurve(p, l)}</div><p>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.</p><div class="footer">Roast plan · Grams & Fahrenheit</div></section>
<section class="sheet"><div class="record-intro"><h1>Roast schedule & record</h1><div class="subtitle">${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster</div>
<h2>Preheat</h2><div class="cards">${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 || "____"))}</div><p>Follow a repeatable preheat routine. Record setpoint separately from the charge BT shown on the curve.</p></div>
<h2>Actuator schedule</h2><table><thead><tr><th>When</th><th>Heat</th><th>Fan</th><th>BT (°F)</th><th>Purpose</th></tr></thead><tbody>${sr}</tbody></table>
<div class="callout">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.</div>
<div class="callout">Watch RoR, color and aroma. Anticipate the machine’s response and avoid abrupt late cuts. Test one change in the next batch.</div>
<h2>At-the-roaster record</h2><table><thead><tr><th>Milestone</th><th>Plan time / BT</th><th>Actual time</th><th>Actual BT (°F)</th><th>Notes</th></tr></thead><tbody>${p
.map((v) => {
const a = plan.planActual?.[v.key] ?? {};
@@ -134,8 +134,8 @@ export function renderReportBody(plan, name = "") {
})
.join("")}</tbody></table>
<h2>After the roast</h2><div class="cards">${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")}</div>
<h2>Cup / brew notes</h2><div class="notes">${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}</div><h2>One change for the next batch</h2><div class="notes">${e(after.oneChange)}</div>
<h2>Sources & interpretation</h2><p><a href="https://library.sweetmarias.com/first-crack-faq-what-is-first-crack-what-is-second-crack/">Sweet Maria’s: First Crack FAQ</a> — audible event and temperature context.</p><p><a href="https://www.scottrao.com/blog/2020/3/29/coffee-roasting-fundamentals">Scott Rao: Coffee Roasting Fundamentals</a> — probe readings do not transfer directly; time and DTR alone do not establish quality.</p><p>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.</p><div class="footer">Roast record · Grams & Fahrenheit · Page 2</div></section></div>`;
<div class="record-notes"><div><h2>Cup / brew notes</h2><div class="notes">${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}</div></div><div><h2>One change for the next batch</h2><div class="notes">${e(after.oneChange)}</div></div></div>
<h2>Sources & interpretation</h2><p><a href="https://library.sweetmarias.com/first-crack-faq-what-is-first-crack-what-is-second-crack/">Sweet Maria’s: First Crack FAQ</a> — audible event and temperature context.</p><p><a href="https://www.scottrao.com/blog/2020/3/29/coffee-roasting-fundamentals">Scott Rao: Coffee Roasting Fundamentals</a> — probe readings do not transfer directly; time and DTR alone do not establish quality.</p><p>Cultivar timings, the 1:15 light-development suggestion floor and the illustrated curve are planning heuristics. Your explicit development override takes precedence.</p><div class="footer">Roast record · Grams & Fahrenheit</div></section></div>`;
}
export function renderReportHtml(plan, name = "") {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${e(name || plan.fields?.["0.1"] || "Roast plan")}</title><style>body{margin:0}${REPORT_CSS}</style></head><body>${renderReportBody(plan, name)}</body></html>`;
+71
View File
@@ -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,
};
});
}
+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,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);
+91
View File
@@ -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, "[email protected]");
const other = request.agent(app);
await signup(other, "[email protected]");
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, "[email protected]");
const other = request.agent(app);
const { csrf } = await signup(other, "[email protected]");
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", ["[email protected]"])
).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,
);
});
+211 -47
View File
@@ -3,13 +3,13 @@ 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";
test(
"browser: unit toggles preserve milestone inputs; HTML download and print share the report",
{ skip: process.env.RUN_BROWSER_TESTS !== "1" },
async (t) => {
const browserOptions = { skip: process.env.RUN_BROWSER_TESTS !== "1" };
async function session(t) {
const { chromium } = await import("playwright");
const { app } = await setup();
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)));
@@ -24,46 +24,61 @@ test(
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", {
const res = await page.request.post(base + "/api/auth/signup", {
data: {
email: "[email protected]",
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",
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",
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,
);
3.2: "-0:30",
1.7: "-0:05",
5.2: "76.14",
});
return p;
}
test(
"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();
@@ -72,24 +87,173 @@ test(
.getByRole("heading", { name: "Milestone plan" })
.waitFor();
await page.getByRole("button", { name: "Close preview" }).click();
const pending = page.waitForEvent("download");
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "Download HTML" }).click();
const download = await pending;
assert.match(download.suggestedFilename(), /Burundi.*\.html$/);
const download = await downloading;
const html = await readFile(await download.path(), "utf8");
assert.ok(html.includes("440 g"));
assert.ok(html.includes("350°F"));
assert.ok(html.includes("761.4"));
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");
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, []);
},
);
+76
View File
@@ -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, "[email protected]");
const other = request.agent(app);
const o = await signup(other, "[email protected]");
const owner = (
await db.query("SELECT id FROM users WHERE email=$1", [
"[email protected]",
])
).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,
);
});