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
+1480 -809

No files matched your search

+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>
+35 -226
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");
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();
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;
}
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 };
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;
}
const requestedId = new URLSearchParams(location.search).get("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;
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();
+507 -118
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,61 +795,79 @@ 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) => {
event.preventDefault();
if (!openId) return;
const form = event.target;
const after = {};
for (const field of AFTER_FIELDS) after[field] = form[field].value;
const button = document.getElementById("after-save");
button.disabled = true;
try {
await api(`/api/roasts/${openId}`, {
method: "PUT",
body: JSON.stringify({ after }),
});
afterFormDirty = false;
showToast("After-roast saved — included in the updated .alog.");
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
document
.getElementById("after-form")
.addEventListener("submit", async (event) => {
event.preventDefault();
if (!openId) return;
const form = event.target;
const after = {};
for (const field of AFTER_FIELDS) after[field] = form[field].value;
const button = document.getElementById("after-save");
button.disabled = true;
try {
await api(`/api/roasts/${openId}`, {
method: "PUT",
body: JSON.stringify({ after }),
});
afterFormDirty = false;
showToast("After-roast saved — included in the updated .alog.");
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
// Open (or create) the cupping session for this roast's plan — same flow the planner offers.
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");
return;
}
button.disabled = true;
try {
const existing = await api(`/api/cupping?plan=${encodeURIComponent(planId)}`).catch(() => ({ sessions: [] }));
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
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",
);
return;
}
const created = await api("/api/cupping", {
method: "POST",
body: JSON.stringify({ roastPlanId: planId }),
});
location.assign(`/cupping?session=${created.session.id}`);
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
button.disabled = true;
try {
const existing = await api(
`/api/cupping?plan=${encodeURIComponent(planId)}`,
).catch(() => ({ sessions: [] }));
if (existing.sessions?.length) {
location.assign(`/cupping?session=${existing.sessions[0].id}`);
return;
}
const created = await api("/api/cupping", {
method: "POST",
body: JSON.stringify({ roastPlanId: planId }),
});
location.assign(`/cupping?session=${created.session.id}`);
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
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,36 +902,43 @@ async function uploadFiles(files) {
await loadRoasts();
}
document.getElementById("roast-upload").addEventListener("change", async (event) => {
const files = [...(event.target.files ?? [])];
event.target.value = "";
if (files.length) await uploadFiles(files);
});
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) => {
if (!openId || event.currentTarget.disabled) return;
const button = event.currentTarget;
button.disabled = true;
try {
await api(`/api/roasts/${openId}/evaluate`, { method: "POST" });
showToast("Re-evaluation queued.");
await loadRoasts();
await openDetail(openId, { keepScroll: true });
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
document
.getElementById("detail-reevaluate")
.addEventListener("click", async (event) => {
if (!openId || event.currentTarget.disabled) return;
const button = event.currentTarget;
button.disabled = true;
try {
await api(`/api/roasts/${openId}/evaluate`, { method: "POST" });
showToast("Re-evaluation queued.");
await loadRoasts();
await openDetail(openId, { keepScroll: true });
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
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">