Test and deploy / test-and-deploy (push) Successful in 1m6s
Brewing: - roasted_beans + brews tables; /beans (bean management with LLM URL prefill) and /brews (silhouette brewer picker across immersion/ percolation/espresso, recipe fields, auto ratio, 0-10 rating, tasting notes); bean remaining weight derived from logged brew doses - Green inventory lot form also prefills from a product URL Navigation/UX: - Side nav is now generated from one definition in nav.js, grouped Roasting / Brewing / account, consistent on every page LLM: - 'Ask the LLM' chat drawer on the planner (stateless /api/plan-chat) grounded in the plan, computed ledger, learned pace, and a new roaster-behavior profile aggregated from uploaded .alogs (/api/roaster-profile: TP lag, phase RoR, median milestone temps) - The profile also feeds roast reviews and the planner curve's fallback milestone temps API platform: - User-generated bearer tokens (rpt_…) with account-page management; token requests skip CSRF; hand-authored OpenAPI 3 spec at /api/openapi.json rendered by self-hosted Swagger UI at /api-docs - Full-database backup export/import (admin) + per-user data export Co-Authored-By: Claude Fable 5 <[email protected]>
275 lines
9.3 KiB
JavaScript
275 lines
9.3 KiB
JavaScript
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
|
|
|
let beans = [];
|
|
let showArchived = false;
|
|
let editingId = null;
|
|
|
|
const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—");
|
|
|
|
async function guarded(button, run) {
|
|
if (!button || button.disabled) return;
|
|
button.disabled = true;
|
|
try {
|
|
await run();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
function renderBeans() {
|
|
const body = document.getElementById("beans-body");
|
|
const visible = showArchived ? beans : beans.filter((b) => !b.archived);
|
|
if (!visible.length) {
|
|
body.innerHTML = `<tr><td colspan="6" class="empty-state">No beans yet. Add a bag below — paste the roaster's product URL to prefill the details.</td></tr>`;
|
|
return;
|
|
}
|
|
body.replaceChildren(
|
|
...visible.map((bean) => {
|
|
const tr = document.createElement("tr");
|
|
if (bean.archived) tr.className = "archived";
|
|
|
|
const beanCell = document.createElement("td");
|
|
const strong = document.createElement("strong");
|
|
strong.textContent = bean.name;
|
|
const sub = document.createElement("div");
|
|
sub.className = "muted";
|
|
sub.style.fontSize = "11.5px";
|
|
sub.textContent =
|
|
[bean.roaster, bean.roastLevel].filter(Boolean).join(" · ") || "—";
|
|
beanCell.append(strong, sub);
|
|
|
|
const originCell = document.createElement("td");
|
|
originCell.textContent =
|
|
[bean.origin, bean.process].filter(Boolean).join(" · ") || "—";
|
|
|
|
const roastedCell = document.createElement("td");
|
|
roastedCell.textContent = fmtDate(bean.roastDate);
|
|
|
|
const remainingCell = document.createElement("td");
|
|
if (bean.initialWeightG == null) remainingCell.textContent = "—";
|
|
else {
|
|
const pct = bean.initialWeightG > 0
|
|
? Math.max(0, Math.min(100, (bean.remainingWeightG / bean.initialWeightG) * 100))
|
|
: 0;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "lot-remaining";
|
|
const bar = document.createElement("div");
|
|
bar.className = "blend-total-bar";
|
|
const fill = document.createElement("div");
|
|
fill.className = "blend-total-fill";
|
|
if (bean.remainingWeightG < 0) fill.classList.add("over");
|
|
fill.style.width = `${bean.remainingWeightG < 0 ? 0 : pct}%`;
|
|
bar.append(fill);
|
|
const label = document.createElement("span");
|
|
label.className = "blend-total-label";
|
|
label.textContent = `${Math.round(bean.remainingWeightG)} g of ${Math.round(bean.initialWeightG)} g`;
|
|
wrap.append(bar, label);
|
|
remainingCell.append(wrap);
|
|
}
|
|
|
|
const brewsCell = document.createElement("td");
|
|
brewsCell.className = "num";
|
|
const brewsLink = document.createElement("a");
|
|
brewsLink.href = `/brews?bean=${encodeURIComponent(bean.id)}`;
|
|
brewsLink.textContent = String(bean.brewCount ?? 0);
|
|
brewsCell.append(brewsLink);
|
|
|
|
const actions = document.createElement("td");
|
|
actions.className = "data-table-actions";
|
|
const brewBtn = document.createElement("a");
|
|
brewBtn.className = "ghost-btn small";
|
|
brewBtn.href = `/brews?new=1&bean=${encodeURIComponent(bean.id)}`;
|
|
brewBtn.textContent = "Brew";
|
|
const editBtn = document.createElement("button");
|
|
editBtn.className = "ghost-btn small";
|
|
editBtn.type = "button";
|
|
editBtn.textContent = "Edit";
|
|
editBtn.addEventListener("click", () => startEdit(bean));
|
|
const archiveBtn = document.createElement("button");
|
|
archiveBtn.className = "ghost-btn small";
|
|
archiveBtn.type = "button";
|
|
archiveBtn.textContent = bean.archived ? "Unarchive" : "Archive";
|
|
archiveBtn.addEventListener("click", (event) =>
|
|
guarded(event.currentTarget, async () => {
|
|
try {
|
|
await api(`/api/beans/${bean.id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ archived: !bean.archived }),
|
|
});
|
|
showToast(bean.archived ? "Bean unarchived." : "Bean archived.");
|
|
await loadBeans();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
}),
|
|
);
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.className = "ghost-btn small";
|
|
deleteBtn.type = "button";
|
|
deleteBtn.textContent = "Delete";
|
|
deleteBtn.addEventListener("click", (event) =>
|
|
guarded(event.currentTarget, async () => {
|
|
if (
|
|
!confirm(
|
|
`Delete "${bean.name}"? Its logged brews are kept but lose the bean link.`,
|
|
)
|
|
)
|
|
return;
|
|
try {
|
|
await api(`/api/beans/${bean.id}`, { method: "DELETE" });
|
|
showToast("Bean deleted.");
|
|
if (editingId === bean.id) cancelEdit();
|
|
await loadBeans();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
}),
|
|
);
|
|
actions.append(brewBtn, editBtn, archiveBtn, deleteBtn);
|
|
|
|
tr.append(beanCell, originCell, roastedCell, remainingCell, brewsCell, actions);
|
|
return tr;
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function loadBeans() {
|
|
try {
|
|
beans = (await api("/api/beans")).beans;
|
|
renderBeans();
|
|
} catch {
|
|
document.getElementById("beans-body").innerHTML =
|
|
`<tr><td colspan="6" class="empty-state">Could not load beans.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function startEdit(bean) {
|
|
editingId = bean.id;
|
|
const form = document.getElementById("bean-form");
|
|
form.name.value = bean.name;
|
|
form.roaster.value = bean.roaster;
|
|
form.origin.value = bean.origin;
|
|
form.process.value = bean.process;
|
|
form.variety.value = bean.variety;
|
|
form.roastLevel.value = bean.roastLevel;
|
|
form.roastDate.value = bean.roastDate ? bean.roastDate.slice(0, 10) : "";
|
|
form.initialWeightG.value = bean.initialWeightG ?? "";
|
|
form.tastingNotes.value = bean.tastingNotes;
|
|
form.url.value = bean.url;
|
|
form.notes.value = bean.notes;
|
|
document.getElementById("bean-form-title").textContent = "Edit bean";
|
|
document.getElementById("bean-form-submit").textContent = "Save changes";
|
|
document.getElementById("bean-form-cancel").classList.remove("hidden");
|
|
document.getElementById("bean-form-card").scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editingId = null;
|
|
const form = document.getElementById("bean-form");
|
|
form.reset();
|
|
document.getElementById("bean-form-title").textContent = "Add a bean";
|
|
document.getElementById("bean-form-submit").textContent = "Add bean";
|
|
document.getElementById("bean-form-cancel").classList.add("hidden");
|
|
}
|
|
|
|
// The LLM 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");
|
|
const note = document.getElementById("bean-prefill-note");
|
|
button.addEventListener("click", () =>
|
|
guarded(button, async () => {
|
|
const url = document.getElementById("bean-prefill-url").value.trim();
|
|
if (!url) return;
|
|
note.classList.remove("hidden");
|
|
note.textContent = "Reading the page with the LLM…";
|
|
try {
|
|
const body = await api("/api/prefill", {
|
|
method: "POST",
|
|
body: JSON.stringify({ url }),
|
|
});
|
|
const x = body.extracted ?? {};
|
|
const form = document.getElementById("bean-form");
|
|
const setIf = (field, value) => {
|
|
if (value) form[field].value = value;
|
|
};
|
|
setIf("name", x.coffeeName);
|
|
setIf("roaster", x.producer);
|
|
setIf("origin", x.origin);
|
|
setIf("process", x.process);
|
|
setIf("variety", x.cultivar);
|
|
setIf("roastLevel", x.roastLevel);
|
|
if (Array.isArray(x.tastingNotes) && x.tastingNotes.length)
|
|
form.tastingNotes.value = x.tastingNotes.join(", ");
|
|
form.url.value = url;
|
|
const filled = ["coffeeName", "producer", "origin", "process", "cultivar", "roastLevel"]
|
|
.filter((k) => x[k]).length + (x.tastingNotes?.length ? 1 : 0);
|
|
note.textContent = filled
|
|
? `Prefilled ${filled} field${filled === 1 ? "" : "s"} — check them, then save.`
|
|
: "The page didn't state anything usable — fill the form by hand.";
|
|
} catch (error) {
|
|
note.textContent = `Prefill failed: ${error.message}`;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
document.getElementById("bean-form-cancel").addEventListener("click", cancelEdit);
|
|
document.getElementById("show-archived").addEventListener("change", (event) => {
|
|
showArchived = event.target.checked;
|
|
renderBeans();
|
|
});
|
|
document.getElementById("bean-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const form = event.target;
|
|
const data = Object.fromEntries(new FormData(form));
|
|
guarded(document.getElementById("bean-form-submit"), async () => {
|
|
try {
|
|
const payload = {
|
|
name: data.name,
|
|
roaster: data.roaster,
|
|
origin: data.origin,
|
|
process: data.process,
|
|
variety: data.variety,
|
|
roastLevel: data.roastLevel,
|
|
roastDate: data.roastDate || null,
|
|
initialWeightG: data.initialWeightG === "" ? null : Number(data.initialWeightG),
|
|
tastingNotes: data.tastingNotes,
|
|
url: data.url,
|
|
notes: data.notes,
|
|
};
|
|
if (editingId) {
|
|
await api(`/api/beans/${editingId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
showToast("Bean updated.");
|
|
} else {
|
|
await api("/api/beans", { method: "POST", body: JSON.stringify(payload) });
|
|
showToast("Bean added.");
|
|
}
|
|
cancelEdit();
|
|
await loadBeans();
|
|
} catch (error) {
|
|
showToast(error.message, "fail");
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById("btn-logout").addEventListener("click", async () => {
|
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
|
location.assign("/");
|
|
});
|
|
|
|
async function init() {
|
|
initSideNav();
|
|
const user = await loadNavUser();
|
|
if (!user) return;
|
|
wirePrefill();
|
|
await loadBeans();
|
|
}
|
|
|
|
init();
|