Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s

This commit is contained in:
snowspeeder committed 2026-09-06 18:43:33 -04:00
1 parent 1fd53091ac
commit 8c21750356
28 files changed
+3848 -384

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
import { api } from "./api.js?v=__ASSET_VERSION__";
import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__";
import { buildPlanCurve } from "/shared/curve.js?v=__ASSET_VERSION__";
import { formatDuration } from "/shared/time.js?v=__ASSET_VERSION__";
const n = (tag, text, cls = "") => {
const e = document.createElement(tag);
e.textContent = text;
e.className = cls;
return e;
};
export function initPlannerWorkspace({
state,
recompute,
renderFormFromPlan,
flushCurrentPlan,
getRemotePlanId,
rename,
}) {
document.body.classList.add("planner-page");
const form = document.getElementById("plan-form"),
layout = n("div", "", "planner-layout"),
main = n("div", "", "planner-main");
form.before(layout);
main.append(form);
layout.append(main);
const chat = document.getElementById("panel-chat");
chat.className = "bean-bro-panel";
chat.removeAttribute("aria-hidden");
chat.removeAttribute("aria-modal");
chat.setAttribute("role", "complementary");
chat.querySelector("[data-close-drawer]").remove();
layout.append(chat);
const tabs = n("nav", "", "mobile-workspace-tabs");
tabs.setAttribute("aria-label", "Planner workspace");
const planTab = n("button", "Plan", "ghost-btn"),
broTab = n("button", "Ask Bean Bro", "ghost-btn");
const setTab = (bro) => {
document.body.classList.toggle("bro-tab", bro);
planTab.setAttribute("aria-pressed", String(!bro));
broTab.setAttribute("aria-pressed", String(bro));
};
planTab.onclick = () => setTab(false);
broTab.onclick = () => {
setTab(true);
chat.scrollIntoView({ block: "start" });
};
setTab(new URLSearchParams(location.search).get("intent")==="next-experiment");
tabs.append(planTab, broTab);
layout.after(tabs);
const navchat = document.getElementById("nav-chat");
navchat.textContent = "Ask Bean Bro";
navchat.onclick = () => {
setTab(true);
chat.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("chat-input").focus();
};
const header = document.querySelector(".header-row-top");
const back = document.getElementById("nav-plans");
back.textContent = "← Your plans";
header.prepend(back);
const title = document.getElementById("header-coffee-name");
title.setAttribute("role", "button");
title.tabIndex = 0;
title.title = "Rename this plan";
const edit = async () => {
const value = prompt("Plan name", title.textContent);
if (value !== null) await rename(value);
};
title.onclick = edit;
title.onkeydown = (e) => {
if (e.key === "Enter") edit();
};
const tools = document.getElementById("btn-tools-menu");
tools.textContent = "More ···";
document.querySelector(".section-nav").replaceChildren(
...[
["Coffee", "#sec-coffee"],
["Roast intent", "#sec-process"],
["Bean details", "#sec-bean"],
].map(([text, href]) => {
const a = n("a", text);
a.href = href;
return a;
}),
);
const source = form.querySelector('[name="sourceUrl"]').closest("label");
source.classList.add("source-field");
source.parentElement.classList.add("coffee-basics");
source.parentElement.append(source);
const fill = document.getElementById("nav-prefill");
fill.textContent = "Fill from URL";
source.append(fill);
fill.addEventListener("click", () => {
document.getElementById("prefill-url").value = state.plan.sourceUrl || "";
});
const grid = form.querySelector(".workspace-grid"),
dock = form.querySelector(".dock"),
curve = dock.querySelector(".curve-card"),
ledger = dock.querySelector(".ledger-card");
grid.prepend(dock);
dock.prepend(curve);
const ledgerDetails = document.createElement("details");
ledgerDetails.className = "calculation-details";
ledger.before(ledgerDetails);
ledgerDetails.append(n("summary", "Timing calculations & checks"), ledger);
for (const card of [...dock.children])
if (card !== curve && card !== ledgerDetails) ledgerDetails.append(card);
const curves = document.getElementById("nav-alog");
curves.textContent = "Attach reference curve";
curve.querySelector(".dock-card-head").append(curves);
curve.append(
n(
"p",
"Planning guide · Timing comes from worksheet rules and your overrides. Temperature references are illustrative; audible first crack and your measurements establish actual milestones.",
"field-note",
),
);
const overview = n("section", "", "plan-overview");
overview.append(n("span", "YOUR NEXT BATCH", "eyebrow"));
const stats = n("div", "", "overview-stats");
overview.append(stats);
main.prepend(overview);
curve.append(
n(
"p",
"Swipe the graph horizontally to see every milestone.",
"mobile-curve-hint field-note",
),
);
const report = n("div", "", "report-actions");
report.id = "report-actions";
curve.append(report);
const experiment = n("section", "", "panel-card experiment-panel");
experiment.id = "sec-experiment";
experiment.append(
n("div", "One change. One question.", "eyebrow"),
n("h2", "Plan an experiment"),
n(
"p",
"Record what you want to learn; evaluate it after tasting.",
"muted",
),
);
const fields = n("div", "", "field-grid");
for (const [key, label, placeholder] of [
["question", "Question", "Can I keep the fruit and improve sweetness?"],
["change", "One change", "Extend development by 10 seconds"],
[
"expected",
"Expected cup result",
"More sweetness without losing clarity",
],
]) {
const l = n("label", label, "field"),
input = n("input");
input.name = "experiment." + key;
input.className = "field-input";
input.placeholder = placeholder;
l.append(input);
fields.append(l);
}
experiment.append(fields);
form.querySelector(".form-col").append(experiment);
const revisions = n("button", "Plan history", "ghost-btn small");
document.getElementById("header-tools-group").append(revisions);
const history = document.createElement("dialog");
history.className = "workspace-dialog";
document.body.append(history);
revisions.onclick = async () => {
history.replaceChildren(n("h2", "Plan history"));
const close = n("button", "Close", "ghost-btn");
close.onclick = () => history.close();
history.append(close);
history.showModal();
if (!(await flushCurrentPlan()))
return history.append(n("p", "Save the plan before opening history."));
try {
const data = await api(`/api/plans/${getRemotePlanId()}/revisions`);
if (!data.revisions.length)
history.append(n("p", "No earlier revisions yet."));
for (const r of data.revisions) {
const row = n("div", "", "history-row");
row.append(
n(
"p",
`${new Date(r.created_at).toLocaleString()} · ${r.name || "Plan inputs"}`,
),
);
const restore = n("button", "Restore this revision", "ghost-btn");
restore.onclick = async () => {
if (
!confirm(
"Restore these planning inputs? The current version will remain in history.",
)
)
return;
try {
await api(`/api/plans/${getRemotePlanId()}/restore`, {
method: "POST",
body: JSON.stringify({ revisionId: r.id }),
});
location.assign("/app?plan=" + getRemotePlanId());
} catch (e) {
history.append(n("p", e.message));
}
};
row.append(restore);
history.append(row);
}
} catch (e) {
history.append(n("p", e.message));
}
};
const roast = n("button", "Roast day", "primary-btn");
document.querySelector(".header-actions").prepend(roast);
const dialog = document.createElement("dialog");
dialog.className = "workspace-dialog roast-day";
document.body.append(dialog);
let timer;
roast.onclick = () => {
dialog.replaceChildren(
n("h2", "Roast day"),
n(
"p",
"Record actual events against the plan. Your draft stays on this device offline; sync resumes when connected. Export this record or attach an Artisan file after roasting.",
"muted",
),
);
const close = n("button", "Close roast day", "ghost-btn");
close.onclick = () => dialog.close();
const clock = n("div", "0:00", "roast-clock"),
start = n(
"button",
state.plan.roastDay?.startedAt ? "Restart timer" : "Start roast",
"primary-btn",
);
const records = n("div", "", "roast-event-grid");
const update = () => {
clock.textContent = state.plan.roastDay?.startedAt
? formatDuration(
Math.max(
0,
Math.floor((Date.now() - state.plan.roastDay.startedAt) / 1000),
),
)
: "0:00";
};
start.onclick = () => {
if (
state.plan.roastDay?.startedAt &&
!confirm(
"Start a new roast-day record? Export the previous record first if needed.",
)
)
return;
state.plan.roastDay = { startedAt: Date.now(), events: {} };
recompute();
renderEvents();
update();
};
const renderEvents = () => {
records.replaceChildren();
const points = buildPlanCurve(state.plan, computeLedger(state.plan));
for (const p of points) {
const row = n("div", "", "roast-event");
row.append(
n("strong", p.label || p.key),
n(
"span",
`Plan ${formatDuration(p.timeS)} · ${Math.round(p.tempC * 1.8 + 32)}°F`,
),
);
const saved = state.plan.roastDay?.events?.[p.key];
row.append(
n(
"span",
saved ? `Actual ${formatDuration(saved.timeS)}` : "Not recorded",
),
);
const temp = n("input");
temp.type = "number";
temp.placeholder = "Actual °F";
temp.setAttribute(
"aria-label",
(p.label || p.key) + " actual Fahrenheit",
);
temp.value = saved?.tempF ?? "";
const mark = n("button", "Mark event", "ghost-btn");
mark.onclick = () => {
if (!state.plan.roastDay?.startedAt)
return alert("Start the roast timer first.");
state.plan.roastDay.events ??= {};
state.plan.roastDay.events[p.key] = {
timeS: Math.max(
0,
Math.floor((Date.now() - state.plan.roastDay.startedAt) / 1000),
),
tempF: temp.value,
};
recompute();
renderEvents();
};
row.append(temp, mark);
records.append(row);
}
};
const exportButton = n("button", "Export roast-day record", "ghost-btn");
exportButton.onclick = () => {
const blob = new Blob(
[
JSON.stringify(
{
planId: getRemotePlanId(),
coffee: state.plan.fields["0.1"],
...state.plan.roastDay,
},
null,
2,
),
],
{ type: "application/json" },
),
url = URL.createObjectURL(blob),
a = document.createElement("a");
a.href = url;
a.download = "roast-day.json";
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const finish = n("button", "Finish & record tasting", "primary-btn");
const finishStatus = n("p", "", "field-note");
finish.onclick = async () => {
finish.disabled = true;
try {
if (!(await flushCurrentPlan())) {
finishStatus.textContent =
"Your draft stays on this device. Reconnect to create the finished-roast record.";
return;
}
const result = await api("/api/roasts/manual", {
method: "POST",
body: JSON.stringify({ planId: getRemotePlanId() }),
});
location.assign("/roasts?roast=" + result.roast.id);
} catch (e) {
finishStatus.textContent = e.message;
} finally {
finish.disabled = false;
}
};
dialog.append(
clock,
start,
records,
finish,
exportButton,
close,
finishStatus,
);
renderEvents();
update();
timer = setInterval(update, 1000);
dialog.showModal();
};
dialog.addEventListener("close", () => clearInterval(timer));
const update = () => {
const l = computeLedger(state.plan);
stats.replaceChildren(
...[
["Coffee", state.plan.fields["0.1"] || "Choose your coffee"],
["Batch", `${state.plan.fields["0.4"] || "—"} g`],
["Roast", state.plan.fields["4.1"] || "Choose a level"],
["Total", formatDuration(l.D)],
["Development", formatDuration(l.C)],
].map(([label, value]) => {
const e = n("div");
e.append(n("span", label), n("strong", value));
return e;
}),
);
};
form.addEventListener("input", update);
document.addEventListener("plan-updated", update);
update();
}