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,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();
|
||||
Reference in New Issue
Block a user