400 lines
13 KiB
JavaScript
400 lines
13 KiB
JavaScript
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 calibration = n("div", "", "plan-temperature-offset");
|
||
const offsetLabel = n("label", "", "field");
|
||
offsetLabel.append(n("span", "Gauge temperature offset (°F)", "field-label"));
|
||
const offsetInput = n("input", "", "field-input");
|
||
offsetInput.name = "temperatureOffsetF";
|
||
offsetInput.type = "number";
|
||
offsetInput.step = "any";
|
||
offsetInput.placeholder = "0";
|
||
offsetInput.setAttribute("aria-describedby", "temperature-offset-help");
|
||
offsetLabel.append(offsetInput);
|
||
const offsetHelp = n("p", "Gauge reads 50°F lower? Enter −50. Shifts blank event-temperature references; drop follows the adjusted milestones. Entered temperatures, preheat and recorded roasts stay as entered.", "field-note");
|
||
offsetHelp.id = "temperature-offset-help";
|
||
calibration.append(offsetLabel, offsetHelp);
|
||
curve.append(calibration);
|
||
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();
|
||
}
|