Test and deploy / test-and-deploy (push) Successful in 1m8s
The planner's Artisan .alog drawer now shows what's attached with a "Remove reference curve" control (re-rendered per drawer open), so adding a reference curve is no longer a one-way door; /api/alog shares the 8 MB body cap so real-sized logs parse instead of failing with "bad_request". Deep-evaluation fixes: editing a brew of an archived bean no longer silently detaches the bean; the roasts pending-review poll no longer wipes in-progress after-roast edits; roasters gain an Edit (rename/model) action; the gear page refuses to autosave over a failed load; duplicating a plan carries its custom name; cupping sessions can attach a plan after creation (ownership-checked PUT + selector); admin user deletion also refreshes plans/audit; cupping cup-count subtitle stays live; roasts error-row colspan corrected. Regression tests cover the new cupping PUT and the /api/alog body cap. Academy scenes drop the flat paper-cutout look: shared defs provide radial-gradient shading on every bean/half-bean/particle, flame gradients with radiant halos, soft ground shadows, and a warm-lit stage background; fill-shift animations now ride a partial-opacity tint overlay so shading survives the color change. Co-Authored-By: Claude Fable 5 <[email protected]>
444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
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;
|
|
// Owned equipment from /gear: when brewers are configured, the picker shows only those
|
|
// (with a show-all escape hatch), and grinders become input suggestions.
|
|
let gear = { brewers: [], grinders: [] };
|
|
let showAllBrewers = false;
|
|
|
|
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 visibleMethods() {
|
|
if (!gear.brewers.length || showAllBrewers) return BREW_METHODS;
|
|
// An old brew being edited may use a brewer that's since been removed from the gear list —
|
|
// keep its tile visible so the selection isn't silently unrepresentable.
|
|
return BREW_METHODS.filter(
|
|
(m) => gear.brewers.includes(m.key) || m.key === selectedMethod,
|
|
);
|
|
}
|
|
|
|
function renderBrewerPicker() {
|
|
const mount = document.getElementById("brewer-picker");
|
|
mount.replaceChildren();
|
|
const methods = visibleMethods();
|
|
for (const category of BREW_CATEGORIES) {
|
|
const inCategory = methods.filter((m) => m.category === category.key);
|
|
if (!inCategory.length) continue;
|
|
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 inCategory) {
|
|
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);
|
|
}
|
|
if (gear.brewers.length) {
|
|
const toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = "ghost-btn small";
|
|
toggle.style.marginTop = "10px";
|
|
toggle.textContent = showAllBrewers
|
|
? "Show only my brewers"
|
|
: "Show all brewers…";
|
|
toggle.addEventListener("click", () => {
|
|
showAllBrewers = !showAllBrewers;
|
|
renderBrewerPicker();
|
|
});
|
|
mount.append(toggle);
|
|
} else {
|
|
const hint = document.createElement("p");
|
|
hint.className = "field-note";
|
|
hint.style.marginTop = "10px";
|
|
const link = document.createElement("a");
|
|
link.href = "/gear";
|
|
link.textContent = "Set up your equipment";
|
|
hint.append(link, " to only see the brewers you own here.");
|
|
mount.append(hint);
|
|
}
|
|
}
|
|
|
|
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(keepId) {
|
|
// keepId: a bean that must stay listed even if archived — editing an old brew of a
|
|
// finished bag must not silently detach the bean (that would inflate its derived
|
|
// remaining weight when saved).
|
|
const select = document.getElementById("brew-bean-select");
|
|
const current = keepId ?? 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.recipe) {
|
|
const steps = document.createElement("div");
|
|
steps.className = "muted";
|
|
steps.style.fontSize = "11.5px";
|
|
steps.style.maxWidth = "340px";
|
|
steps.textContent = brew.recipe;
|
|
recipeCell.append(steps);
|
|
}
|
|
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");
|
|
renderBeanSelect(brew.beanId ?? ""); // the edited brew's bean may be archived
|
|
form.beanId.value = brew.beanId ?? "";
|
|
selectedMethod = brew.method;
|
|
renderBrewerPicker(); // the edited brew's method may be outside the owned-gear filter
|
|
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.recipe.value = brew.recipe;
|
|
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),
|
|
recipe: form.recipe.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;
|
|
try {
|
|
gear = (await api("/api/gear")).gear;
|
|
} catch {
|
|
/* no gear configured — picker shows everything */
|
|
}
|
|
renderBrewerPicker();
|
|
const grinderOptions = document.getElementById("grinder-options");
|
|
if (grinderOptions)
|
|
grinderOptions.replaceChildren(
|
|
...gear.grinders.map((g) =>
|
|
Object.assign(document.createElement("option"), { value: g }),
|
|
),
|
|
);
|
|
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();
|