Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s
Test and deploy / test-and-deploy (push) Successful in 1m21s
This commit is contained in:
1 parent
1fd53091ac
commit
8c21750356
28 files changed
+3848
-384
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user