Files
snowspeeder eda3d8e77f
Test and deploy / test-and-deploy (push) Successful in 1m23s
Add explicit per-plan gauge temperature offset
2026-09-07 07:39:33 -04:00

400 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}