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