Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s
Test and deploy / test-and-deploy (push) Successful in 1m43s
This commit is contained in:
1 parent
824a964f9d
commit
5f3a015ea2
20 files changed
+1480
-809
No files matched your search
@@ -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
@@ -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 & 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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user