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

+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();
});