426 lines
12 KiB
JavaScript
426 lines
12 KiB
JavaScript
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;
|
|
}
|