386 lines
12 KiB
JavaScript
386 lines
12 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 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();
|
|
}
|