Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s

This commit is contained in:
snowspeeder committed 2026-09-06 18:43:33 -04:00
1 parent 1fd53091ac
commit 8c21750356
28 files changed
+3848 -384

No files matched your search

+7 -11
View File
@@ -97,10 +97,10 @@ async function loadLlm() {
const select = document.getElementById("llm-model-select");
const note = document.getElementById("llm-model-note");
try {
const { current, models, modelsError } = await api("/api/admin/llm");
const { current, models, modelsError, effective } = await api("/api/admin/llm");
const auto = document.createElement("option");
auto.value = "";
auto.textContent = "Auto (first available model)";
auto.textContent = "Auto (server default)";
select.replaceChildren(
auto,
...models.map((model) => {
@@ -119,15 +119,9 @@ async function loadLlm() {
select.append(missing);
}
select.value = current;
note.textContent = modelsError
? "No models are configured on the server — reviews and prefill will fail until one is set up."
: current
? ""
: models.length
? `Auto currently resolves to ${models[0].name} (${models[0].provider}).`
: "";
note.textContent = modelsError ? "Could not list providers. Check the server configuration." : `Effective selection: ${effective || "No model configured"}. Provider credentials are managed on the server.`;
} catch {
note.textContent = "Could not load LLM settings.";
note.textContent = "Could not load Bean Bro settings.";
}
}
@@ -141,7 +135,7 @@ function wireLlmSave() {
method: "PUT",
body: JSON.stringify({ model }),
});
showToast(model ? "LLM model saved." : "LLM model set to auto.");
showToast(model ? "Bean Bro model saved." : "Bean Bro model set to auto.");
await loadLlm();
} catch (error) {
showToast(error.message, "fail");
@@ -439,3 +433,5 @@ async function init() {
}
init();
document.getElementById('bro-test').addEventListener('click',async e=>{e.currentTarget.disabled=true;const out=document.getElementById('bro-test-result');out.textContent='Contacting Bean Bro…';try{const result=await api('/api/admin/llm/test',{method:'POST'});out.textContent=`Connected: ${result.model||'configured provider'} · ${result.reply}`;}catch(error){out.textContent=error.message;}finally{document.getElementById('bro-test').disabled=false;}});
+1 -1
View File
@@ -114,7 +114,7 @@ export function initAlogPanel({
}
});
// Finished-roast uploads: each file becomes an actual_roasts row attached to the open plan
// (which must exist server-side first — hence the flush), and the LLM reviews it
// (which must exist server-side first — hence the flush), and Bean Bro reviews it
// asynchronously; the roast history page is where results land.
actualInput?.addEventListener("change", async (e) => {
const files = [...(e.target.files ?? [])];
+2 -2
View File
@@ -174,7 +174,7 @@ function cancelEdit() {
document.getElementById("bean-form-cancel").classList.add("hidden");
}
// The LLM prefill endpoint returns raw `extracted` page facts — map them straight into the
// Bean Bro prefill endpoint returns raw `extracted` page facts — map them straight into the
// bean form (only overwriting fields the page actually stated).
function wirePrefill() {
const button = document.getElementById("bean-prefill-btn");
@@ -184,7 +184,7 @@ function wirePrefill() {
const url = document.getElementById("bean-prefill-url").value.trim();
if (!url) return;
note.classList.remove("hidden");
note.textContent = "Reading the page with the LLM…";
note.textContent = "Reading the page with Bean Bro…";
try {
const body = await api("/api/prefill", {
method: "POST",
+2 -2
View File
@@ -253,7 +253,7 @@ document.getElementById("lot-form").addEventListener("submit", (event) => {
});
});
// The LLM prefill endpoint returns raw `extracted` page facts — map the green-relevant ones
// Bean Bro prefill endpoint returns raw `extracted` page facts — map the green-relevant ones
// into the lot form (only overwriting what the page actually stated).
document.getElementById("lot-prefill-btn").addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
@@ -261,7 +261,7 @@ document.getElementById("lot-prefill-btn").addEventListener("click", (event) =>
const note = document.getElementById("lot-prefill-note");
if (!url) return;
note.classList.remove("hidden");
note.textContent = "Reading the page with the LLM…";
note.textContent = "Reading the page with Bean Bro…";
try {
const body = await api("/api/prefill", {
method: "POST",
+1 -1
View File
@@ -26,7 +26,7 @@ form.addEventListener("submit", async (event) => {
);
return;
}
location.assign("/app");
location.assign("/home");
} catch {
showError("Could not reach the server. Check your connection and try again.");
} finally {
+13 -5
View File
@@ -1,3 +1,4 @@
import { initPlannerWorkspace } from "./planner-workspace.js?v=__ASSET_VERSION__";
import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__";
import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__";
import {
@@ -414,6 +415,7 @@ function hasVal(obj, blank) {
}
export function recompute() {
document.dispatchEvent(new Event("plan-updated"));
const ledger = renderLedger();
renderCurve(ledger);
renderBandMarkers();
@@ -482,7 +484,7 @@ function syncPlan(snapshot = structuredClone(state.plan), locallySaved = persist
draftSyncedAt = body.plan.updated_at;
currentPlanName = body.plan.name ?? currentPlanName;
// A confirmed server save succeeds even when browser storage is full/disabled.
persistDraft(snapshot);
persistDraft(structuredClone(state.plan));
history.replaceState(null, "", `/app?plan=${encodeURIComponent(remotePlanId)}`);
lastSaveError = "";
setSaveStatus("saved");
@@ -501,6 +503,9 @@ async function flushCurrentPlan() {
return await syncPlan(snapshot, persistDraft(snapshot));
}
function autosave() {
// Preserve each edit immediately; debounce only the network write. Reloads and
// offline roast-day events must not depend on a later timer firing.
persistDraft(structuredClone(state.plan));
setSaveStatus("saving");
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
@@ -681,7 +686,7 @@ function wireDrawers() {
for (const [buttonId, panelId] of [
["nav-prefill", "panel-prefill"],
["nav-alog", "panel-alog"],
["nav-chat", "panel-chat"],
["nav-settings", "panel-settings"],
["nav-import-export", "panel-import-export"],
])
@@ -853,7 +858,7 @@ function wirePwa() {
// "controllerchange" is not an update — only a page that was already controlled by a
// previous service worker should reload when a new one takes over.
const hadController = !!navigator.serviceWorker.controller;
window.addEventListener("load", () => {
const registerWorker = () => {
navigator.serviceWorker
.register("/sw.js?v=__ASSET_VERSION__")
.then((registration) => {
@@ -870,7 +875,9 @@ function wirePwa() {
.catch((error) =>
console.warn("Service worker registration failed:", error),
);
});
};
if (document.readyState === "complete") registerWorker();
else window.addEventListener("load", registerWorker, {once:true});
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (hadController) location.reload();
});
@@ -970,6 +977,7 @@ async function init() {
getTempUnit: () => tempUnit,
getMachineProfile: () => machineProfile,
});
initPlannerWorkspace({state,recompute,renderFormFromPlan,flushCurrentPlan,getRemotePlanId:()=>remotePlanId,rename:async name=>{if(!await flushCurrentPlan())return showToast(lastSaveError,"fail");try{const result=await api(`/api/plans/${remotePlanId}`,{method:"PUT",body:JSON.stringify({name})});currentPlanName=result.plan.name;renderFormFromPlan();}catch(e){showToast(e.message,"fail");}}});
renderBandRanges();
renderFormFromPlan();
wireForm();
@@ -1002,7 +1010,7 @@ async function init() {
renderFormFromPlan,
});
wireCuppingLink();
initPlanChat({ getPlan: () => state.plan });
initPlanChat({ getPlan: () => state.plan, recompute, renderFormFromPlan, flushCurrentPlan, getRemotePlanId:()=>remotePlanId });
renderMachineProfileNote();
recompute();
}
+2
View File
@@ -8,6 +8,8 @@ const NAV_GROUPS = [
{
title: "Roasting",
items: [
{ href: "/home", icon: "⌂", label: "Today" },
{ href: "/coffees", icon: "◉", label: "Coffee history" },
{ href: "/plans", icon: "◐", label: "Plans" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
{ href: "/roasters", icon: "♨", label: "Machines" },
+246 -40
View File
@@ -1,55 +1,261 @@
// Chat-with-the-LLM drawer on the planner: stateless per page load, grounded server-side in
// the current plan + learned profiles. The whole visible conversation is resent each turn.
import { api } from "./api.js?v=__ASSET_VERSION__";
export function initPlanChat({ getPlan }) {
const thread = document.getElementById("chat-thread");
const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const send = document.getElementById("chat-send");
if (!thread || !form) return;
const messages = [];
function bubble(role, content, pending = false) {
const div = document.createElement("div");
div.className = `chat-msg ${role}${pending ? " pending" : ""}`;
div.textContent = content;
thread.append(div);
thread.scrollTop = thread.scrollHeight;
return div;
import {
SUGGESTION_FIELDS,
cleanSuggestions,
} from "/shared/workspace.js?v=__ASSET_VERSION__";
import { computeLedger } from "/shared/ledger.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 initPlanChat({
getPlan,
recompute,
renderFormFromPlan,
flushCurrentPlan,
getRemotePlanId,
}) {
const thread = document.getElementById("chat-thread"),
form = document.getElementById("chat-form"),
input = document.getElementById("chat-input"),
send = document.getElementById("chat-send");
let busy = false;
const memory = () =>
getPlan().assistant ?? (getPlan().assistant = { messages: [] });
memory().messages ??= [];
const starters = n("div", "", "chat-starters");
for (const text of [
"Review this plan",
"Aim for more fruit",
"Explain these timings",
"Suggest one experiment using my tasting history",
]) {
const b = n("button", text, "ghost-btn small");
b.type = "button";
b.onclick = () => {
input.value = text;
form.requestSubmit();
};
starters.append(b);
}
bubble(
"assistant",
"Ask me anything about this plan — why a number is what it is, what to change for a different cup, or how your machine's history should shape it.",
thread.before(
n(
"p",
"Your plan, your tasting history, and one useful next experiment. Suggestions never change your plan until you apply them.",
"bro-intro",
),
starters,
);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const content = input.value.trim();
if (!content || send.disabled) return;
input.value = "";
messages.push({ role: "user", content });
bubble("user", content);
const pending = bubble("assistant", "Thinking…", true);
const suggestions = n("div", "", "bro-suggestions");
thread.after(suggestions);
const draw = () => {
thread.replaceChildren();
for (const m of memory().messages)
thread.append(n("div", m.content, "chat-msg " + m.role));
thread.scrollTop = thread.scrollHeight;
};
draw();
const showSuggestions = (raw) => {
suggestions.replaceChildren();
for (const proposal of cleanSuggestions(raw)) {
const panel = n("section", "", "proposal-card");
panel.append(
n("h3", "A change to review"),
n("p", proposal.reason),
n(
"p",
"Expected: " +
(proposal.expected || "Taste and record whether this helps."),
),
);
const table = n("table", "", "data-table");
const head = n("tr");
for (const label of ["Setting", "Current", "Suggested"])
head.append(n("th", label));
table.append(head);
const before = structuredClone(
memory().suggestionBase || getPlan().fields,
);
for (const [key, value] of Object.entries(proposal.fields)) {
const row = n("tr");
row.append(
n("td", SUGGESTION_FIELDS[key]),
n("td", before[key] || "—"),
n("td", value),
);
table.append(row);
}
panel.append(table);
const candidate = {
...getPlan(),
fields: { ...getPlan().fields, ...proposal.fields },
};
const l = computeLedger(candidate);
panel.append(
n(
"p",
`Preview: first crack ${formatDuration(l.A)}, development ${formatDuration(l.C)}, total ${formatDuration(l.D)}.`,
"field-note",
),
);
for (const warning of l.warnings || [])
panel.append(
n(
"p",
typeof warning === "string" ? warning : JSON.stringify(warning),
"field-note",
),
);
for (const id of proposal.evidenceRoastIds) {
const a = n("a", "View supporting roast");
a.href = "/roasts?roast=" + encodeURIComponent(id);
panel.append(a);
}
const apply = n("button", "Apply changes", "primary-btn"),
variation = n("button", "Create a variation", "ghost-btn"),
undo = n("button", "Undo changes", "ghost-btn");
undo.hidden = true;
const stale = () =>
Object.keys(before).some((k) => getPlan().fields[k] !== before[k]);
apply.onclick = () => {
if (stale()) {
panel.append(
n(
"p",
"The plan changed since this suggestion. Ask Bean Bro to review it again.",
),
);
return;
}
memory().undo = {
fields: structuredClone(getPlan().fields),
experiment: structuredClone(getPlan().experiment || {}),
};
Object.assign(getPlan().fields, proposal.fields);
getPlan().experiment = {
...getPlan().experiment,
change: proposal.reason,
expected: proposal.expected,
};
renderFormFromPlan();
recompute();
apply.disabled = true;
undo.hidden = false;
};
undo.onclick = () => {
const previous = memory().undo;
if (!previous) return;
if (
Object.entries(proposal.fields).some(
([k, v]) => getPlan().fields[k] !== v,
)
) {
panel.append(
n(
"p",
"These settings have changed again. Use Plan history to review earlier versions.",
),
);
return;
}
for (const k of Object.keys(proposal.fields))
getPlan().fields[k] = previous.fields[k];
getPlan().experiment = previous.experiment;
delete memory().undo;
renderFormFromPlan();
recompute();
showSuggestions([]);
};
variation.onclick = async () => {
if (stale()) {
panel.append(
n(
"p",
"The plan changed. Request a fresh suggestion before creating a variation.",
),
);
return;
}
if (!(await flushCurrentPlan()))
return panel.append(
n("p", "Save the current plan before creating a variation."),
);
variation.disabled = true;
try {
const copy = (
await api(`/api/plans/${getRemotePlanId()}/clone`, {
method: "POST",
})
).plan;
Object.assign(copy.plan.fields, proposal.fields);
copy.plan.experiment = {
...copy.plan.experiment,
question: "Test Bean Bro’s suggestion",
change: proposal.reason,
expected: proposal.expected,
};
await api(`/api/plans/${copy.id}`, {
method: "PUT",
body: JSON.stringify({ plan: copy.plan }),
});
location.assign("/app?plan=" + copy.id);
} catch (e) {
panel.append(n("p", e.message));
variation.disabled = false;
}
};
panel.append(apply, variation, undo);
suggestions.append(panel);
}
};
showSuggestions(memory().suggestions || []);
if(new URLSearchParams(location.search).get("intent")==="next-experiment") input.value="Use my tasting history to suggest one experiment for the next batch. Explain the expected cup result and how to evaluate it.";
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || busy) return;
busy = true;
send.disabled = true;
input.value = "";
const messages = memory().messages;
messages.push({ role: "user", content: text.slice(0, 4000) });
memory().messages = messages.slice(-30);
draw();
recompute();
const pending = n(
"div",
"Bean Bro is reviewing your plan…",
"chat-msg assistant pending",
);
thread.append(pending);
const sentFields = structuredClone(getPlan().fields);
try {
const body = await api("/api/plan-chat", {
const result = await api("/api/plan-chat", {
method: "POST",
body: JSON.stringify({ plan: getPlan(), messages }),
body: JSON.stringify({ plan: getPlan(), messages: memory().messages }),
});
messages.push({ role: "assistant", content: body.reply });
pending.classList.remove("pending");
pending.textContent = body.reply;
memory().messages.push({ role: "assistant", content: result.reply });
memory().messages = memory().messages.slice(-30);
memory().suggestionBase = sentFields;
memory().suggestions = cleanSuggestions(result.suggestions);
memory().model = result.model;
draw();
showSuggestions(memory().suggestions);
recompute();
} catch (error) {
messages.pop(); // keep history consistent with what's on screen
pending.classList.remove("pending");
pending.textContent =
error.code === "no_model"
? "No LLM model is configured on the server — ask an admin to set one on the Admin page."
: `That didn't work: ${error.message}`;
? "Bean Bro is not connected. An admin can configure the provider in Admin → Bean Bro."
: `Could not get a reply. ${error.message}`;
memory().messages.pop();
input.value = text;
recompute();
} finally {
busy = false;
send.disabled = false;
thread.scrollTop = thread.scrollHeight;
}
});
}
+385
View File
@@ -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();
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function initPrint({ getPlan, getName }) {
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
print.after(download);
(document.getElementById("report-actions") || print.parentElement).append(download);
const preview = document.createElement("button");
preview.type = "button";
preview.className = "ghost-btn";
+25 -12
View File
@@ -125,7 +125,7 @@ async function loadRoasts() {
}
// While any review is still pending, refresh every few seconds so the table/detail fill in as
// the LLM finishes; stops by itself once nothing is pending.
// Bean Bro finishes; stops by itself once nothing is pending.
function schedulePoll() {
clearTimeout(pollTimer);
if (!roasts.some((roast) => roast.evaluationStatus === "pending")) return;
@@ -419,7 +419,7 @@ function evaluationList(title, items) {
function renderEvaluation(detail) {
const box = document.getElementById("detail-evaluation");
box.replaceChildren();
const heading = el("h3", {}, "LLM review");
const heading = el("h3", {}, "Bean Bro review");
heading.style.margin = "0 0 6px";
box.append(heading);
if (detail.evaluationStatus === "pending") {
@@ -427,7 +427,7 @@ function renderEvaluation(detail) {
el(
"p",
{ className: "field-note" },
"The LLM is reviewing this roast — this page refreshes automatically.",
"Bean Bro is reviewing this roast — this page refreshes automatically.",
),
);
return;
@@ -448,7 +448,7 @@ function renderEvaluation(detail) {
el(
"p",
{ className: "field-note" },
"No review recorded for this roast.",
detail.roast?.manual ? "Manual milestones recorded. Add tasting feedback below; these points do not provide a continuous RoR trace." : "No review recorded for this roast.",
),
);
return;
@@ -532,6 +532,12 @@ async function openDetail(id, { keepScroll = false } = {}) {
}),
);
planSelect.value = detail.roastPlanId ?? "";
if (!detail.roastPlanId) {
const words=String(detail.roast?.title||detail.filename).toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>3);
const ranked=planList.map(p=>({p,score:words.filter(w=>String(p.coffee||p.title).toLowerCase().includes(w)).length})).filter(r=>r.score>=2).sort((a,b)=>b.score-a.score);
if(ranked.length){const suggested=ranked[0].p;const button=el("button",{type:"button",className:"ghost-btn small"},`Suggested match: ${suggested.title} — attach`);button.addEventListener("click",()=>{planSelect.value=suggested.id;planSelect.dispatchEvent(new Event("change"));});planWrap.append(button);}
}
planSelect.addEventListener("change", async () => {
try {
await api(`/api/roasts/${detail.id}`, {
@@ -635,6 +641,8 @@ async function openDetail(id, { keepScroll = false } = {}) {
}
document.getElementById("detail-download").href =
`/api/roasts/${encodeURIComponent(id)}/download`;
document.getElementById("detail-download-updated").hidden=Boolean(detail.roast?.manual);
document.getElementById("detail-download").textContent=detail.roast?.manual?"Download manual record":"Download original .alog";
document.getElementById("detail-download-updated").href =
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
// The 4s pending-evaluation poll re-runs openDetail; never let that background refresh
@@ -665,7 +673,7 @@ let comparisonDirty = false;
function renderComparison(detail) {
comparisonDirty = false;
document.getElementById("comparison-plan-note").textContent = detail.plan
? "* Illustrative plan temperature; actual readings may use a different probe reference."
? `${detail.hasPlanSnapshot?"Saved target snapshot":"Current linked plan (no historical snapshot)"}. * Illustrative temperature; actual readings may use a different probe reference.`
: "Attach a plan above to see its targets alongside your observations.";
const rows = buildComparison(detail);
document.getElementById("comparison-body").replaceChildren(
@@ -762,7 +770,7 @@ document
// ---- after the roast (moved here from the plan worksheet) -------------------
const AFTER_FIELDS = [
const AFTER_FIELDS = ["tasteBalance", "sweetness", "favorite", "experimentResult",
"greenIn",
"out",
"weightLossPct",
@@ -786,6 +794,7 @@ document.getElementById("after-form").addEventListener("input", () => {
function renderAfterForm(detail) {
const form = document.getElementById("after-form");
const after = detail.after ?? {};
document.getElementById("experiment-expectation").textContent = detail.plan?.experiment?.expected ? "Expected result: " + detail.plan.experiment.expected : "Record one change and what you expect to taste in the plan’s experiment section.";
// Prefill measurable values straight from the .alog so the user only types what the
// machine couldn't know (colour, cup notes, the one change).
const auto = {
@@ -808,18 +817,20 @@ document
const after = {};
for (const field of AFTER_FIELDS) after[field] = form[field].value;
const button = document.getElementById("after-save");
button.disabled = true;
if(button.disabled)return;
const submitButtons=[...form.querySelectorAll('button[type="submit"]')];
submitButtons.forEach(b=>b.disabled=true);
try {
await api(`/api/roasts/${openId}`, {
method: "PUT",
body: JSON.stringify({ after }),
});
afterFormDirty = false;
showToast("After-roast saved — included in the updated .alog.");
showToast("Tasting and roast notes saved.");
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
submitButtons.forEach(b=>b.disabled=false);
}
});
@@ -879,27 +890,29 @@ function closeDetail() {
async function uploadFiles(files) {
const status = document.getElementById("upload-status");
status.hidden = false;
let uploaded = 0;
let uploaded = 0, newestId = null;
for (const file of files) {
status.textContent = `Uploading ${file.name}…`;
try {
await api("/api/roasts", {
const uploadedRoast = await api("/api/roasts", {
method: "POST",
body: JSON.stringify({
filename: file.name,
content: await file.text(),
}),
});
newestId = uploadedRoast.roast.id;
uploaded++;
} catch (error) {
showToast(`${file.name}: ${error.message}`, "fail");
}
}
status.textContent = uploaded
? `Uploaded ${uploaded} roast${uploaded === 1 ? "" : "s"} — the LLM review runs in the background.`
? `Uploaded ${uploaded} roast${uploaded === 1 ? "" : "s"} — Bean Bro review runs in the background.`
: "";
status.hidden = !status.textContent;
await loadRoasts();
if (newestId) await openDetail(newestId);
}
document
+425
View File
@@ -0,0 +1,425 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
const root = document.getElementById("workspace-content");
const n = (tag, text, cls = "") => {
const e = document.createElement(tag);
e.textContent = text;
e.className = cls;
return e;
};
const link = (text, href, cls = "") => {
const e = n("a", text, cls);
e.href = href;
return e;
};
const time = (s) =>
s == null
? "—"
: `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
const feedback = (r) =>
[
r.after.favorite === "yes" ? "★ Your favorite" : "",
r.after.tasteBalance,
r.after.sweetness ? `Sweetness ${r.after.sweetness}/5` : "",
r.after.cupNotes,
]
.filter(Boolean)
.join(" · ") || "Not tasted yet";
let data;
function coffeeCards(coffees) {
const grid = n("div", "", "coffee-grid");
for (const c of coffees) {
const card = n("article", "", "coffee-card");
card.append(
n("span", "COFFEE HISTORY", "eyebrow"),
link(
c.name,
"/coffees?coffee=" + encodeURIComponent(c.key),
"coffee-name",
),
n(
"p",
`${c.plans.length} plans · ${c.roasts.length} roasts${c.lot ? ` · ${c.lot.remainingG} g remaining` : ""}`,
"muted",
),
);
const best = c.roasts.find((r) => r.after.favorite === "yes");
card.append(
n(
"p",
best
? `Your pick: ${best.title}`
: "Taste a batch and mark your favorite.",
),
);
grid.append(card);
}
return grid;
}
function render() {
root.replaceChildren();
const selected = new URLSearchParams(location.search).get("coffee");
const coffee = data.coffees.find((c) => c.key === selected);
if (coffee) {
renderCoffee(coffee);
return;
}
const home = location.pathname === "/home";
document.getElementById("workspace-title").textContent = home
? "Your next good roast"
: "Your coffees";
document.getElementById("workspace-subtitle").textContent = home
? "Plan, roast, taste, and learn from the next batch."
: "The plans, experiments and cups behind each coffee.";
if (home) {
const panel = n("section", "", "panel-card next-actions");
panel.append(n("h2", "Next actions"));
if (!data.actions.length)
panel.append(
n("p", "All caught up. Choose a coffee or start a new plan."),
);
for (const a of data.actions.slice(0, 12)) {
const row = n("div", "", "next-action");
row.append(link(a.title, a.href), n("p", a.detail, "muted"));
panel.append(row);
}
root.append(panel);
}
const search = n("input");
search.type = "search";
search.placeholder = "Find a coffee";
search.setAttribute("aria-label", "Find a coffee");
search.className = "field-input";
const results = n("div");
const update = () => {
const q = search.value.toLowerCase();
results.replaceChildren(
coffeeCards(data.coffees.filter((c) => c.name.toLowerCase().includes(q))),
);
};
search.addEventListener("input", update);
root.append(search, results);
update();
if (!data.coffees.length)
results.append(
n("p", "Your coffee history begins with a plan or an inventory lot."),
link("Create your first plan", "/plans", "primary-btn"),
);
}
function renderCoffee(c) {
document.getElementById("workspace-title").textContent = c.name;
document.getElementById("workspace-subtitle").textContent =
"A history of what you planned, roasted, and tasted.";
root.append(link("← All coffees", "/coffees"));
const best = c.roasts.find((r) => r.after.favorite === "yes");
const intro = n("section", "", "panel-card coffee-intro");
intro.append(
n("h2", best ? "Your favorite batch" : "Find your favorite batch"),
n(
"p",
best
? `${best.title} — ${feedback(best)}`
: "Record a quick tasting on a finished roast to build your reference.",
),
link(
best ? "Open this roast" : "Record a roast",
best ? `/roasts?roast=${best.id}` : "/roasts",
"primary-btn",
),
);
if (c.lot)
intro.append(
n("p", `${c.lot.remainingG} g of green coffee remaining`),
link("Inventory", "/inventory"),
);
if(c.plans[0])intro.append(link("Plan the next experiment",`/app?plan=${c.plans[0].id}&intent=next-experiment`,"ghost-btn"));
root.append(intro);
const plans = n("section", "", "panel-card");
plans.append(n("h2", "Plans & experiments"));
const planFilter = n("input");
planFilter.type = "search";
planFilter.placeholder = "Find a plan or experiment";
planFilter.setAttribute("aria-label", "Find a plan or experiment");
planFilter.className = "field-input";
const planRows = n("div"),
planPager = n("div", "", "collection-pagination"),
planPrev = n("button", "Previous plans", "ghost-btn"),
planNext = n("button", "Next plans", "ghost-btn"),
planCount = n("span");
let planPage = 1;
const renderPlans = () => {
planRows.replaceChildren();
const matches = c.plans.filter((p) =>
[p.name, p.level, p.experiment?.question]
.join(" ")
.toLowerCase()
.includes(planFilter.value.toLowerCase()),
);
planPage = Math.min(planPage, Math.max(1, Math.ceil(matches.length / 10)));
planCount.textContent = `${matches.length} plans · page ${planPage}`;
planPrev.disabled = planPage === 1;
planNext.disabled = planPage * 10 >= matches.length;
for (const p of matches.slice((planPage - 1) * 10, planPage * 10)) {
const row = n("div", "", "history-row");
row.append(
link(p.name, `/app?plan=${p.id}`),
n("p", `${p.level || "Level not set"} · ${p.batchG || "—"} g`, "muted"),
);
if (p.experiment?.question) row.append(n("p", p.experiment.question));
if (p.experiment?.expected)
row.append(n("p", "Expected: " + p.experiment.expected));
const clone = n("button", "Create variation", "ghost-btn small");
clone.onclick = async () => {
clone.disabled = true;
try {
const r = await api(`/api/plans/${p.id}/clone`, { method: "POST" });
location.assign("/app?plan=" + r.plan.id);
} catch (e) {
clone.disabled = false;
alert(e.message);
}
};
row.append(clone);
planRows.append(row);
}
};
planFilter.oninput = () => {
planPage = 1;
renderPlans();
};
planPrev.onclick = () => {
planPage--;
renderPlans();
};
planNext.onclick = () => {
planPage++;
renderPlans();
};
planPager.append(planPrev, planCount, planNext);
plans.append(planFilter, planRows, planPager);
renderPlans();
root.append(plans);
const section = n("section", "", "panel-card");
section.append(
n("h2", "Roast & taste history"),
n(
"p",
"Select two batches to compare measured curves and tasting feedback.",
"muted",
),
);
const wrap = n("div", "", "table-wrap");
const table = n("table", "", "data-table");
const head = n("tr");
for (const t of [
"Compare",
"Batch",
"First crack",
"Development",
"Drop",
"Tasting / experiment",
])
head.append(n("th", t));
const thead = n("thead");
thead.append(head);
table.append(thead);
const body = n("tbody");
const selectedIds = new Set(),
roastFilter = n("input"),
roastPager = n("div", "", "collection-pagination"),
roastPrev = n("button", "Previous roasts", "ghost-btn"),
roastNext = n("button", "Next roasts", "ghost-btn"),
roastCount = n("span");
let roastPage = 1;
roastFilter.type = "search";
roastFilter.placeholder = "Find a batch or tasting note";
roastFilter.setAttribute("aria-label", "Find a batch or tasting note");
roastFilter.className = "field-input";
section.append(roastFilter);
const renderRoasts = () => {
body.replaceChildren();
const matches = c.roasts.filter((r) =>
(r.title + " " + feedback(r))
.toLowerCase()
.includes(roastFilter.value.toLowerCase()),
);
roastPage = Math.min(
roastPage,
Math.max(1, Math.ceil(matches.length / 25)),
);
roastCount.textContent = `${matches.length} roasts · page ${roastPage}`;
roastPrev.disabled = roastPage === 1;
roastNext.disabled = roastPage * 25 >= matches.length;
for (const r of matches.slice((roastPage - 1) * 25, roastPage * 25)) {
const tr = n("tr"),
cell = n("td"),
check = n("input");
check.type = "checkbox";
check.value = r.id;
check.checked = selectedIds.has(r.id);
check.onchange = () => {
if (check.checked) selectedIds.add(r.id);
else selectedIds.delete(r.id);
};
check.setAttribute("aria-label", "Compare " + r.title);
cell.append(check);
const title = n("td");
title.append(link(r.title, `/roasts?roast=${r.id}`));
tr.append(
cell,
title,
n("td", time(r.derived.firstCrackS)),
n("td", time(r.derived.developmentS)),
n("td", time(r.derived.dropS)),
n(
"td",
feedback(r) +
(r.after.experimentResult
? ` · Experiment: ${r.after.experimentResult}`
: ""),
),
);
body.append(tr);
}
};
roastFilter.oninput = () => {
roastPage = 1;
renderRoasts();
};
roastPrev.onclick = () => {
roastPage--;
renderRoasts();
};
roastNext.onclick = () => {
roastPage++;
renderRoasts();
};
roastPager.append(roastPrev, roastCount, roastNext);
renderRoasts();
table.append(body);
wrap.append(table);
section.append(wrap, roastPager);
const compare = n("button", "Compare selected batches", "primary-btn");
const result = n("div", "", "comparison-chart");
compare.onclick = async () => {
const ids = [...selectedIds];
if (ids.length !== 2) {
result.textContent = "Select exactly two batches.";
return;
}
compare.disabled = true;
try {
const details = await Promise.all(
ids.map((id) => api("/api/roasts/" + id)),
);
drawComparison(
result,
details.map((d) => d.roast || d),
);
} catch (e) {
result.textContent = e.message;
} finally {
compare.disabled = false;
}
};
section.append(compare, result);
root.append(section);
if (c.cuppings.length) {
const cups = n("section", "", "panel-card");
cups.append(n("h2", "Detailed cupping"));
for (const cup of c.cuppings)
cups.append(
link(
`${new Date(cup.date).toLocaleDateString()} · ${cup.score.toFixed(2)} points`,
`/cupping?session=${cup.id}`,
),
n("br"),
);
root.append(cups);
}
}
function drawComparison(root, details) {
root.replaceChildren();
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "0 0 800 340");
svg.setAttribute("role", "img");
svg.setAttribute(
"aria-label",
"Measured roast curves, Fahrenheit versus time",
);
const make = (tag, attrs, text) => {
const el = document.createElementNS(ns, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
if (text) el.textContent = text;
svg.append(el);
};
const curves = details.map((d) =>
(d.parsed?.curve || [])
.map((p) => ({ t: p.t ?? p.timeS, bt: p.bt ?? p.tempC }))
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.bt)),
);
const maxT = Math.max(60, ...curves.flat().map((p) => p.t)),
temps = curves.flat().map((p) => p.bt * 1.8 + 32);
const lo = Math.floor(Math.min(100, ...temps) / 50) * 50,
hi = Math.ceil(Math.max(400, ...temps) / 50) * 50;
const x = (t) => 55 + (t / maxT) * 710,
y = (f) => 285 - ((f - lo) / (hi - lo)) * 240;
for (let f = lo; f <= hi; f += 50) {
make("line", { x1: 55, x2: 765, y1: y(f), y2: y(f), stroke: "#ded8d0" });
make(
"text",
{ x: 48, y: y(f) + 4, "text-anchor": "end", "font-size": 12 },
`${f}°F`,
);
}
for (let i = 0; i <= 5; i++)
make(
"text",
{
x: x((maxT * i) / 5),
y: 310,
"text-anchor": "middle",
"font-size": 12,
},
time((maxT * i) / 5),
);
curves.forEach((curve, i) => {
make("polyline", {
points: curve.map((p) => `${x(p.t)},${y(p.bt * 1.8 + 32)}`).join(" "),
fill: "none",
stroke: i ? "#a8481a" : "#286350",
"stroke-width": 2,
});
make(
"text",
{
x: 60 + i * 355,
y: 22,
fill: i ? "#a8481a" : "#286350",
"font-size": 12,
},
details[i].parsed?.roast?.title || details[i].filename,
);
});
root.append(
svg,
n(
"p",
"Original imported measurements. Taste notes and observation corrections are shown separately.",
"muted",
),
);
}
initSideNav();
await loadNavUser();
document.getElementById("btn-logout").onclick = async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
};
try {
data = await api("/api/workspace");
render();
} catch (e) {
root.textContent = "Could not load your workspace. " + e.message;
}