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

+4 -4
View File
@@ -100,23 +100,23 @@
</section>
<section class="panel-card" id="llm-card">
<div class="panel-head"><h2>LLM</h2></div>
<div class="panel-head"><h2>Bean Bro</h2></div>
<div class="panel-body">
<p class="muted" style="margin-top:0">
Model used for URL prefill and finished-roast reviews.
Provider and model for Ask Bean Bro, URL prefill, and finished-roast reviews.
</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select
class="text-input"
id="llm-model-select"
style="max-width:360px"
aria-label="LLM model"
aria-label="Bean Bro model"
></select>
<button class="ghost-btn small" type="button" id="llm-model-save">
Save
</button>
</div>
<p class="field-note" id="llm-model-note"></p>
<p class="field-note" id="llm-model-note"></p><button class="ghost-btn" type="button" id="bro-test">Test connection</button><p class="field-note" id="bro-test-result" role="status"></p>
</div>
</section>
+1166 -125
View File
File diff suppressed because it is too large. Load diff
+4 -25
View File
@@ -84,7 +84,7 @@
<div class="header-tools-group" id="header-tools-group">
<a id="nav-plans" href="/plans" class="ghost-btn small">▤ Your plans</a>
<button id="nav-chat" type="button" class="ghost-btn small">
✳ Ask the LLM
✳ Ask Bean Bro
</button>
<button id="nav-prefill" type="button" class="ghost-btn small">
↗ Prefill
@@ -203,7 +203,7 @@
<h4 style="margin:0 0 6px">Log a finished roast</h4>
<p class="field-note" style="margin-top:0">
Upload the actual roast's .alog to attach it to this plan — you can
upload several. The LLM reviews each one in the background.
upload several. Bean Bro reviews each one in the background.
</p>
<label class="filebtn primary-btn"
>Upload finished roast(s)<input
@@ -229,7 +229,7 @@
tabindex="-1"
>
<div class="drawer-head">
<h3 id="chat-title">Ask the LLM about this plan</h3>
<h3 id="chat-title">Ask Bean Bro</h3>
<button
class="icon-btn"
type="button"
@@ -378,28 +378,7 @@
<section class="panel-card" id="sec-coffee">
<div class="panel-head"><h2>The Coffee</h2></div>
<div class="panel-body">
<details class="why-panel" data-why="sec-coffee">
<summary>Why cultivar sets the clock</summary>
<p>
A roast plan is three durations laid end to end: drying +
Maillard + development = the total roast. Cultivar is the
one input that sets a time you cannot negotiate — when
first crack lands. The seed's genetics fix how much sugar
and acid it carries and how heat gets into it, so each
cultivar browns and builds its aromatics on its own
schedule. A Bourbon rushed to a 7:30 first crack tastes
thin and papery no matter how well you handle the rest; a
Gesha dragged to 9:00 goes savory and loses the florals you
paid for.
</p>
<p>
That is why the FC anchor below feeds Time Ledger line 1
and everything else in the plan is a correction of seconds
around it. Treat the cultivar on the bag as your best
hypothesis, not a fact — labels are wrong often enough that
the "After the Roast" section exists to correct them.
</p>
</details>
<details class="why-panel" data-why="sec-coffee"><summary>How to use these starting points</summary><p>Cultivar and process provide a starting hypothesis. They do not determine how a coffee must taste at an exact first-crack time. Refine the plan using audible milestones, actual measurements, and tasting feedback from your own batches.</p></details>
<div class="field-grid">
<label class="field" data-fid="0.1"
><span class="field-label">Coffee / lot</span
+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;
}
+9 -1
View File
@@ -53,7 +53,7 @@
<div class="brand-text">
<h1>Roast history</h1>
<p class="brand-sub">
Finished roasts uploaded from Artisan, with LLM reviews
Finished roasts uploaded from Artisan, with Bean Bro reviews
</p>
</div>
</div>
@@ -162,6 +162,14 @@
.alog download.
</p>
<form id="after-form">
<h3>Quick taste</h3><p class="muted">A short taste record is enough. Detailed cupping is optional.</p>
<div class="field-grid">
<label class="field">Balance<select class="field-input" name="tasteBalance"><option value="">Not tasted</option><option>Too sour</option><option>Balanced</option><option>Too bitter</option></select></label>
<label class="field">Sweetness<select class="field-input" name="sweetness"><option value="">Unrated</option><option value="1">1 — low</option><option>2</option><option>3</option><option>4</option><option value="5">5 — high</option></select></label>
<label class="field">Your preference<select class="field-input" name="favorite"><option value="">Not marked</option><option value="yes">One of my favorites</option><option value="no">Would change next time</option></select></label>
<label class="field">Did the experiment help?<select class="field-input" name="experimentResult"><option value="">Not evaluated</option><option>Improved</option><option>No clear difference</option><option>Worse</option><option>Need another batch</option></select></label>
</div><p id="experiment-expectation" class="field-note"></p><button class="primary-btn" type="submit">Save tasting</button>
<div class="field-grid">
<label class="field"
><span class="field-label">Green in</span>
+2
View File
@@ -13,6 +13,8 @@ const APP_SHELL = [
"/icon.svg?v=__ASSET_VERSION__",
"/brand-mark.svg?v=__ASSET_VERSION__",
"/js/main.js?v=__ASSET_VERSION__",
"/js/planner-workspace.js?v=__ASSET_VERSION__",
"/shared/workspace.js?v=__ASSET_VERSION__",
"/js/api.js?v=__ASSET_VERSION__",
"/js/nav.js?v=__ASSET_VERSION__",
"/js/toast.js?v=__ASSET_VERSION__",
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Your coffee workspace — Roast Planner</title>
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
<link
rel="icon"
href="/icon.svg?v=__ASSET_VERSION__"
type="image/svg+xml"
/>
<meta name="theme-color" content="#A8481A" />
</head>
<body>
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<img
class="brand-mark"
src="/brand-mark.svg?v=__ASSET_VERSION__"
alt=""
aria-hidden="true"
/>
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
<div class="nav-spacer"></div>
<button
class="icon-btn nav-collapse-toggle"
type="button"
id="nav-collapse"
aria-label="Collapse navigation"
title="Collapse navigation"
>
«
</button>
<div class="nav-user">
<div
class="nav-user-avatar"
id="nav-user-avatar"
aria-hidden="true"
></div>
<div class="nav-user-detail">
<span class="nav-user-email" id="account-email"></span>
<button class="nav-user-logout" type="button" id="btn-logout">
Log out
</button>
</div>
</div>
</nav>
<div class="app-workspace" id="app-workspace">
<header class="app-header">
<div class="header-row-top">
<button
class="icon-btn nav-hamburger"
type="button"
id="nav-hamburger"
aria-label="Open navigation"
aria-expanded="false"
>
☰
</button>
<div class="brand">
<div class="brand-text">
<h1 id="workspace-title">Your coffees</h1>
<p class="brand-sub" id="workspace-subtitle">
Find, filter and open your saved roast plans.
</p>
</div>
</div>
</div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<main class="page-content wide" id="workspace-content"><p>Loading your coffee history…</p></main>
</div>
</div>
<script type="module" src="/js/workspace.js?v=__ASSET_VERSION__"></script>
</body>
</html>