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();
+50
View File
@@ -151,6 +151,55 @@ function wireLlmSave() {
});
}
function wireBackupImport() {
const input = document.getElementById("backup-import-file");
const note = document.getElementById("backup-note");
input.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
let backup;
try {
backup = JSON.parse(await file.text());
} catch {
note.textContent = "That file is not valid JSON.";
return;
}
if (backup.format !== "roast-planner-backup") {
note.textContent =
"That file is not a Roast Planner backup export (expected the file downloaded by “Export full backup”).";
return;
}
const userCount = backup.tables?.users?.length ?? 0;
if (
!confirm(
`Replace the ENTIRE database with "${file.name}" (${userCount} user${userCount === 1 ? "" : "s"}, exported ${backup.exportedAt ?? "unknown date"})?\n\nEverything currently stored will be deleted. This cannot be undone.`,
)
)
return;
note.textContent = "Importing…";
try {
const result = await api("/api/admin/backup/import", {
method: "POST",
body: JSON.stringify(backup),
});
note.textContent = `Imported: ${Object.entries(result.counts)
.map(([table, count]) => `${table} ${count}`)
.join(", ")}.`;
showToast("Backup imported.");
if (!result.sessionKept) {
alert("The restored data does not include your current session's account — log in again with the restored credentials.");
location.assign("/login");
return;
}
await Promise.all([loadMetrics(), loadUsers(), loadPlans(), loadAudit()]);
} catch (error) {
note.textContent = `Import failed: ${error.message}. Nothing was changed.`;
showToast(error.message, "fail");
}
});
}
function roleBadge(user) {
return user.role === "admin"
? `<span class="badge badge-admin">Admin</span>`
@@ -376,6 +425,7 @@ async function init() {
currentUserId = user.id;
wireSignupToggle();
wireLlmSave();
wireBackupImport();
await Promise.all([
loadMetrics(),
loadResetLinks(),
+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();
+376
View File
@@ -0,0 +1,376 @@
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__";
import {
BREW_CATEGORIES,
BREW_METHODS,
BREW_SILHOUETTES,
findBrewMethod,
} from "/shared/brew-data.js?v=__ASSET_VERSION__";
const SVG_NS = "http://www.w3.org/2000/svg";
let beans = [];
let brews = [];
let editingId = null;
let selectedMethod = null;
let beanFilter = null;
const fmtDate = (v) => (v ? new Date(v).toLocaleDateString() : "—");
const fmtTime = (s) =>
s == null
? null
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function parseTime(str) {
if (!str || !str.trim()) return null;
const m = str.trim().match(/^(\d+):(\d{1,2})$/);
if (m) return Number(m[1]) * 60 + Number(m[2]);
const n = Number(str);
return Number.isInteger(n) && n >= 0 ? n : undefined; // undefined = unparseable
}
function silhouetteSvg(methodKey, size = 42) {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 64 64");
svg.setAttribute("width", size);
svg.setAttribute("height", size);
svg.setAttribute("aria-hidden", "true");
for (const d of BREW_SILHOUETTES[methodKey] ?? []) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "currentColor");
svg.append(path);
}
return svg;
}
const isEspresso = () => findBrewMethod(selectedMethod)?.category === "espresso";
function renderBrewerPicker() {
const mount = document.getElementById("brewer-picker");
mount.replaceChildren();
for (const category of BREW_CATEGORIES) {
const title = document.createElement("div");
title.className = "brewer-cat-title";
title.textContent = category.name;
mount.append(title);
const grid = document.createElement("div");
grid.className = "brewer-grid";
for (const method of BREW_METHODS.filter((m) => m.category === category.key)) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "brewer-tile";
tile.dataset.method = method.key;
tile.classList.toggle("selected", method.key === selectedMethod);
tile.setAttribute("aria-pressed", String(method.key === selectedMethod));
tile.append(silhouetteSvg(method.key));
const name = document.createElement("span");
name.className = "brewer-name";
name.textContent = method.name;
tile.append(name);
tile.addEventListener("click", () => selectMethod(method.key));
grid.append(tile);
}
mount.append(grid);
}
}
function selectMethod(key) {
selectedMethod = key;
for (const tile of document.querySelectorAll(".brewer-tile")) {
const on = tile.dataset.method === key;
tile.classList.toggle("selected", on);
tile.setAttribute("aria-pressed", String(on));
}
// Espresso-style methods log yield-out instead of brew water and have no bloom.
const espresso = isEspresso();
for (const el of document.querySelectorAll("[data-espresso-show]"))
el.classList.toggle("hidden", !espresso);
for (const el of document.querySelectorAll("[data-espresso-hide]"))
el.classList.toggle("hidden", espresso);
updateRatio();
}
function updateRatio() {
const form = document.getElementById("brew-form");
const dose = Number.parseFloat(form.doseG.value);
const out = Number.parseFloat(isEspresso() ? form.yieldG.value : form.waterG.value);
document.getElementById("brew-ratio").value =
Number.isFinite(dose) && dose > 0 && Number.isFinite(out) && out > 0
? `1:${Math.round((out / dose) * 10) / 10}`
: "";
}
function updateRatingPill() {
const value = document.getElementById("brew-rating").value;
const pill = document.getElementById("rating-value");
pill.textContent = value;
pill.classList.toggle("good", Number(value) >= 7.5);
pill.classList.toggle("poor", Number(value) <= 4);
}
function renderBeanSelect() {
const select = document.getElementById("brew-bean-select");
const current = select.value;
select.replaceChildren(
Object.assign(document.createElement("option"), {
value: "",
textContent: "— no bean selected —",
}),
...beans
.filter((b) => !b.archived || b.id === current)
.map((bean) => {
const option = document.createElement("option");
option.value = bean.id;
const remaining =
bean.remainingWeightG == null ? "" : ` (${Math.round(bean.remainingWeightG)} g left)`;
option.textContent = `${bean.name}${bean.roaster ? `${bean.roaster}` : ""}${remaining}`;
return option;
}),
);
select.value = current;
}
function ratingPill(rating) {
const pill = document.createElement("span");
pill.className = "rating-pill";
if (rating == null) pill.textContent = "—";
else {
pill.textContent = String(rating);
if (rating >= 7.5) pill.classList.add("good");
if (rating <= 4) pill.classList.add("poor");
}
return pill;
}
function recipeText(brew) {
const method = findBrewMethod(brew.method);
const parts = [];
const out = method?.category === "espresso" ? brew.yieldG : brew.waterG;
if (brew.doseG != null && out != null)
parts.push(`${brew.doseG} g → ${out} g (1:${Math.round((out / brew.doseG) * 10) / 10})`);
else if (brew.doseG != null) parts.push(`${brew.doseG} g`);
if (brew.waterTempC != null) parts.push(`${brew.waterTempC}°C`);
if (brew.brewTimeS != null) parts.push(fmtTime(brew.brewTimeS));
return parts.join(" · ") || "—";
}
function renderBrews() {
const body = document.getElementById("brews-body");
const visible = beanFilter ? brews.filter((b) => b.beanId === beanFilter) : brews;
document
.getElementById("clear-bean-filter")
.classList.toggle("hidden", !beanFilter);
if (!visible.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No brews logged yet. Pick a brewer above and log your first cup.</td></tr>`;
return;
}
body.replaceChildren(
...visible.map((brew) => {
const tr = document.createElement("tr");
const whenCell = document.createElement("td");
whenCell.textContent = fmtDate(brew.brewedAt);
const methodCell = document.createElement("td");
const chip = document.createElement("span");
chip.className = "method-chip";
chip.append(silhouetteSvg(brew.method, 20));
const methodName = document.createElement("span");
methodName.textContent = findBrewMethod(brew.method)?.name ?? brew.method;
chip.append(methodName);
methodCell.append(chip);
const beanCell = document.createElement("td");
beanCell.textContent = brew.beanName ?? "—";
const recipeCell = document.createElement("td");
recipeCell.textContent = recipeText(brew);
if (brew.tastingNotes) {
const notes = document.createElement("div");
notes.className = "muted";
notes.style.fontSize = "11.5px";
notes.style.maxWidth = "340px";
notes.textContent = brew.tastingNotes;
recipeCell.append(notes);
}
const ratingCell = document.createElement("td");
ratingCell.append(ratingPill(brew.rating));
const actions = document.createElement("td");
actions.className = "data-table-actions";
const editBtn = document.createElement("button");
editBtn.className = "ghost-btn small";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => startEdit(brew));
const againBtn = document.createElement("button");
againBtn.className = "ghost-btn small";
againBtn.type = "button";
againBtn.textContent = "Brew again";
againBtn.title = "Copy this brew's recipe into the form as a new brew";
againBtn.addEventListener("click", () => {
startEdit(brew);
editingId = null;
document.getElementById("brew-form-title").textContent = "Log a brew";
document.getElementById("brew-form-submit").textContent = "Save brew";
document.getElementById("brew-form-cancel").classList.add("hidden");
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", async () => {
if (!confirm("Delete this brew? This cannot be undone.")) return;
try {
await api(`/api/brews/${brew.id}`, { method: "DELETE" });
showToast("Brew deleted.");
if (editingId === brew.id) cancelEdit();
await Promise.all([loadBrews(), loadBeans()]);
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(editBtn, againBtn, deleteBtn);
tr.append(whenCell, methodCell, beanCell, recipeCell, ratingCell, actions);
return tr;
}),
);
}
async function loadBeans() {
try {
beans = (await api("/api/beans")).beans;
renderBeanSelect();
} catch {
/* bean select stays empty; brews still work without beans */
}
}
async function loadBrews() {
try {
brews = (await api("/api/brews")).brews;
renderBrews();
} catch {
document.getElementById("brews-body").innerHTML =
`<tr><td colspan="6" class="empty-state">Could not load brews.</td></tr>`;
}
}
function startEdit(brew) {
editingId = brew.id;
const form = document.getElementById("brew-form");
form.beanId.value = brew.beanId ?? "";
selectMethod(brew.method);
form.doseG.value = brew.doseG ?? "";
form.waterG.value = brew.waterG ?? "";
form.yieldG.value = brew.yieldG ?? "";
form.waterTempC.value = brew.waterTempC ?? "";
form.grinder.value = brew.grinder;
form.grindSetting.value = brew.grindSetting;
form.brewTime.value = fmtTime(brew.brewTimeS) ?? "";
form.bloomTime.value = fmtTime(brew.bloomTimeS) ?? "";
form.rating.value = brew.rating ?? 5;
form.tastingNotes.value = brew.tastingNotes;
form.notes.value = brew.notes;
updateRatio();
updateRatingPill();
document.getElementById("brew-form-title").textContent = "Edit brew";
document.getElementById("brew-form-submit").textContent = "Save changes";
document.getElementById("brew-form-cancel").classList.remove("hidden");
document.getElementById("brew-form-card").scrollIntoView({ behavior: "smooth" });
}
function cancelEdit() {
editingId = null;
const form = document.getElementById("brew-form");
form.reset();
updateRatio();
updateRatingPill();
document.getElementById("brew-form-title").textContent = "Log a brew";
document.getElementById("brew-form-submit").textContent = "Save brew";
document.getElementById("brew-form-cancel").classList.add("hidden");
}
document.getElementById("brew-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!selectedMethod) {
showToast("Pick a brewer first.", "fail");
return;
}
const form = event.target;
const brewTimeS = parseTime(form.brewTime.value);
const bloomTimeS = parseTime(form.bloomTime.value);
if (brewTimeS === undefined || bloomTimeS === undefined) {
showToast("Times must look like m:ss (e.g. 2:45).", "fail");
return;
}
const num = (v) => (v === "" ? null : Number(v));
const payload = {
beanId: form.beanId.value || null,
method: selectedMethod,
doseG: num(form.doseG.value),
waterG: num(form.waterG.value),
yieldG: num(form.yieldG.value),
waterTempC: num(form.waterTempC.value),
grinder: form.grinder.value,
grindSetting: form.grindSetting.value,
brewTimeS,
bloomTimeS,
rating: Number(form.rating.value),
tastingNotes: form.tastingNotes.value,
notes: form.notes.value,
};
const submit = document.getElementById("brew-form-submit");
submit.disabled = true;
try {
if (editingId) {
await api(`/api/brews/${editingId}`, { method: "PUT", body: JSON.stringify(payload) });
showToast("Brew updated.");
} else {
await api("/api/brews", { method: "POST", body: JSON.stringify(payload) });
showToast("Brew logged.");
}
cancelEdit();
await Promise.all([loadBrews(), loadBeans()]);
} catch (error) {
showToast(error.message, "fail");
} finally {
submit.disabled = false;
}
});
document.getElementById("brew-form-cancel").addEventListener("click", cancelEdit);
document.getElementById("brew-form").addEventListener("input", (event) => {
if (["doseG", "waterG", "yieldG"].includes(event.target.name)) updateRatio();
if (event.target.name === "rating") updateRatingPill();
});
document.getElementById("clear-bean-filter").addEventListener("click", () => {
beanFilter = null;
renderBrews();
});
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;
renderBrewerPicker();
updateRatingPill();
await Promise.all([loadBeans(), loadBrews()]);
const params = new URLSearchParams(location.search);
const requestedBean = params.get("bean");
if (requestedBean && beans.some((b) => b.id === requestedBean)) {
if (params.get("new")) {
document.getElementById("brew-bean-select").value = requestedBean;
} else {
beanFilter = requestedBean;
renderBrews();
}
}
}
init();
+32
View File
@@ -250,6 +250,38 @@ document.getElementById("lot-form").addEventListener("submit", (event) => {
});
});
// The LLM 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 () => {
const url = document.getElementById("lot-prefill-url").value.trim();
const note = document.getElementById("lot-prefill-note");
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("lot-form");
if (x.origin) form.origin.value = x.origin;
if (x.cultivar) form.variety.value = x.cultivar;
if (x.process) form.process.value = x.process;
if (x.producer) form.producer.value = x.producer;
if (x.moisturePct) form.moisturePct.value = x.moisturePct;
const filled = ["origin", "cultivar", "process", "producer", "moisturePct"]
.filter((k) => x[k]).length;
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("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
+17 -2
View File
@@ -20,6 +20,7 @@ import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
const FIELD_ID_SET = new Set(FIELD_IDS);
@@ -38,6 +39,18 @@ const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the
// the plan draft cache below.
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
let machineProfile = null;
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
// curve's fallback milestone temps so the suggested curve matches this user's machine.
let roasterProfile = null;
async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
} catch {
/* offline — curve falls back to reference machine temps */
}
}
async function loadMachineProfile(userId) {
const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
@@ -464,7 +477,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger);
const planPoints = buildPlanCurve(state.plan, ledger, roasterProfile);
const ref = state.plan.reference;
paintCurveInto(
@@ -775,6 +788,7 @@ function wireDrawers() {
["nav-prefill", "panel-prefill"],
["nav-alog", "panel-alog"],
["nav-plans", "panel-plans"],
["nav-chat", "panel-chat"],
["nav-settings", "panel-settings"],
["nav-import-export", "panel-import-export"],
])
@@ -1089,7 +1103,7 @@ async function init() {
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
await Promise.all([loadPlans(), loadMachineProfile(user.id), loadRoasterProfile()]);
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
@@ -1165,6 +1179,7 @@ async function init() {
renderFormFromPlan,
});
wireCuppingLink();
initPlanChat({ getPlan: () => state.plan });
renderMachineProfileNote();
recompute();
}
+67
View File
@@ -1,6 +1,73 @@
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
// (planner, account, admin) so each page's own script doesn't reimplement it.
// Single source of truth for the page links, grouped by workflow — every page renders this
// into its <div id="nav-links"> instead of hand-maintaining a diverging copy of the nav.
// Page-specific tool buttons (the planner's drawers) stay in that page's own markup below it.
const NAV_GROUPS = [
{
title: "Roasting",
items: [
{ href: "/app", icon: "◐", label: "Planner" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
{ href: "/inventory", icon: "▥", label: "Green lots" },
{ href: "/cupping", icon: "◒", label: "Cupping" },
],
},
{
title: "Brewing",
items: [
{ href: "/brews", icon: "◉", label: "Brews" },
{ href: "/beans", icon: "◍", label: "Beans" },
],
},
{
title: null,
items: [
{ href: "/account", icon: "◔", label: "Account" },
{ href: "/api-docs", icon: "⌗", label: "API docs" },
{ href: "/admin", icon: "⚙", label: "Admin", id: "nav-admin", hidden: true },
],
},
];
export function renderNavLinks() {
const mount = document.getElementById("nav-links");
if (!mount) return;
mount.replaceChildren(
...NAV_GROUPS.map((group) => {
const wrap = document.createElement("div");
wrap.className = "nav-group";
if (group.title) {
const title = document.createElement("div");
title.className = "nav-group-title";
title.textContent = group.title;
wrap.append(title);
}
for (const item of group.items) {
const a = document.createElement("a");
a.className = "nav-item";
if (item.hidden) a.classList.add("hidden");
if (item.id) a.id = item.id;
a.href = item.href;
if (item.href === location.pathname) a.setAttribute("aria-current", "page");
const icon = document.createElement("span");
icon.className = "nav-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = item.icon;
const label = document.createElement("span");
label.className = "nav-label";
label.textContent = item.label;
a.append(icon, label);
wrap.append(a);
}
return wrap;
}),
);
}
export function initSideNav() {
renderNavLinks();
const nav = document.getElementById("side-nav");
const hamburger = document.getElementById("nav-hamburger");
const collapseBtn = document.getElementById("nav-collapse");
+55
View File
@@ -0,0 +1,55 @@
// 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;
}
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.",
);
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);
send.disabled = true;
try {
const body = await api("/api/plan-chat", {
method: "POST",
body: JSON.stringify({ plan: getPlan(), messages }),
});
messages.push({ role: "assistant", content: body.reply });
pending.classList.remove("pending");
pending.textContent = body.reply;
} 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}`;
} finally {
send.disabled = false;
thread.scrollTop = thread.scrollHeight;
}
});
}
+23
View File
@@ -0,0 +1,23 @@
// Boots the self-hosted Swagger UI (the app's CSP forbids inline scripts and CDNs).
// "Try it out" calls run same-origin, so the browser session cookie authenticates them;
// write endpoints additionally need the x-csrf-token header, which is injected below.
/* global SwaggerUIBundle */
const csrf = () =>
document.cookie
.split("; ")
.find((v) => v.startsWith("rp_csrf="))
?.split("=")[1] || "";
window.addEventListener("DOMContentLoaded", () => {
SwaggerUIBundle({
url: "/api/openapi.json",
dom_id: "#swagger-ui",
docExpansion: "none",
defaultModelsExpandDepth: -1,
requestInterceptor: (request) => {
if (!/^(GET|HEAD)$/i.test(request.method) && !request.headers.Authorization)
request.headers["x-csrf-token"] = csrf();
return request;
},
});
});