Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Test and deploy / test-and-deploy (push) Successful in 1m6s
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]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5efaeb63c9
commit
38c7d01e03
@@ -0,0 +1,274 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user