Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
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:
Shane Maynard
2026-08-08 22:37:42 -04:00
co-authored by Claude Fable 5
parent 5efaeb63c9
commit 38c7d01e03
33 changed files with 3498 additions and 205 deletions
+274
View File
@@ -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();