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

+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;
}