Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s
Test and deploy / test-and-deploy (push) Successful in 1m21s
This commit is contained in:
1 parent
1fd53091ac
commit
8c21750356
28 files changed
+3848
-384
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user