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
+4
-4
@@ -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
File diff suppressed because it is too large.
Load diff
+4
-25
@@ -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
@@ -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;}});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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__",
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user