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