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
+69 -1
View File
@@ -331,6 +331,73 @@ function wirePwaButtons() {
});
}
async function loadTokens() {
const body = document.getElementById("tokens-body");
try {
const { tokens } = await api("/api/tokens");
if (!tokens.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No API tokens yet.</td></tr>`;
return;
}
body.replaceChildren(
...tokens.map((token) => {
const tr = document.createElement("tr");
const name = document.createElement("td");
name.textContent = token.name || "(unnamed)";
const created = document.createElement("td");
created.textContent = fmtDate(token.createdAt);
const used = document.createElement("td");
used.textContent = token.lastUsedAt ? fmtDate(token.lastUsedAt) : "Never";
const actions = document.createElement("td");
actions.className = "data-table-actions";
const revoke = document.createElement("button");
revoke.className = "ghost-btn small";
revoke.type = "button";
revoke.textContent = "Revoke";
revoke.addEventListener("click", async () => {
if (!confirm(`Revoke "${token.name || "this token"}"? Anything using it stops working immediately.`))
return;
try {
await api(`/api/tokens/${token.id}`, { method: "DELETE" });
showToast("Token revoked.");
await loadTokens();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(revoke);
tr.append(name, created, used, actions);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load tokens.</td></tr>`;
}
}
function wireTokenForm() {
document.getElementById("token-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = document.getElementById("token-create");
button.disabled = true;
try {
const created = await api("/api/tokens", {
method: "POST",
body: JSON.stringify({ name: event.target.name.value.trim() }),
});
// Shown exactly once — the server only stores a hash.
document.getElementById("token-reveal").textContent = created.token;
document.getElementById("token-reveal-wrap").classList.remove("hidden");
event.target.reset();
await loadTokens();
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
}
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
@@ -345,7 +412,8 @@ async function init() {
await loadProfile(user);
wireForms(user);
wirePwaButtons();
await Promise.all([loadSessions(), loadPlans()]);
wireTokenForm();
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
init();