Removable reference curves, audit fixes across pages, shaded Academy art
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]>
This commit is contained in:
2026-08-11 16:59:28 -04:00
co-authored by Claude Fable 5
parent 8b80c3fa6f
commit 385474ecef
14 changed files with 368 additions and 34 deletions
+13 -4
View File
@@ -3141,10 +3141,16 @@ select.f {
white-space: nowrap;
}
.academy-stage {
background: var(--surface);
/* Warm-lit paper: a top light and a faint ember glow rising from the floor, so the
* scenes sit in an environment instead of on a flat panel. */
background:
linear-gradient(180deg, rgba(255, 253, 248, 0.65), rgba(255, 253, 248, 0) 45%),
radial-gradient(90% 60% at 50% 115%, rgba(168, 72, 26, 0.07), transparent 60%),
var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 8px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 1px 3px rgba(27, 22, 20, 0.05);
}
.academy-stage svg {
width: 100%;
@@ -3229,7 +3235,10 @@ select.f {
@keyframes ac-pour-kf { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
.ac-pourline { animation: ac-pour-kf 1.6s ease-in-out infinite; }
@keyframes ac-maillard-kf { 0% { fill: #d9ba5a; } 100% { fill: #8a5a30; } }
.ac-maillard-shift ellipse:first-child { animation: ac-maillard-kf 5s ease-in-out infinite alternate; }
/* Fill-shift animations ride a flat .ac-tint overlay at partial opacity so the
* gradient shading underneath survives the color change. */
.ac-tint, .ac-jb-color { opacity: 0.82; }
.ac-maillard-shift .ac-tint { animation: ac-maillard-kf 5s ease-in-out infinite alternate; }
@keyframes ac-tumble-kf { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-8px) rotate(6deg); } }
.ac-tumble { animation: ac-tumble-kf 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes ac-flame-kf { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.12); } }
@@ -3271,7 +3280,7 @@ select.f {
@keyframes ac-fill-up-kf { from { transform: scaleY(0.12); } to { transform: scaleY(0.86); } }
.ac-fill-up { transform-box: fill-box; transform-origin: bottom; animation: ac-fill-up-kf 5s ease-out forwards; }
@keyframes ac-dry-shift-kf { 0% { fill: #7a8c5e; } 100% { fill: #d9ba5a; } }
.ac-dry-shift > ellipse:first-child { animation: ac-dry-shift-kf 6s ease-in-out infinite alternate; }
.ac-dry-shift .ac-tint { animation: ac-dry-shift-kf 6s ease-in-out infinite alternate; }
@keyframes ac-needle-kf { 0%, 100% { transform: rotate(-52deg); } 50% { transform: rotate(52deg); } }
.ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; }
@keyframes ac-pulse-soft-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } }
@@ -3342,7 +3351,7 @@ select.f {
}
.ac-burst { animation: ac-burst-kf 1.1s ease-out infinite; }
@keyframes ac-grass-fade-kf { 0% { fill: #8a9575; } 40% { fill: #7a8c5e; } 100% { fill: #cdbd8f; } }
.ac-grass-fade > ellipse:first-child { animation: ac-grass-fade-kf 6s ease-in-out infinite alternate; }
.ac-grass-fade .ac-tint { animation: ac-grass-fade-kf 6s ease-in-out infinite alternate; }
@keyframes ac-cross-a-kf { 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } }
.ac-cross-a { animation: ac-cross-a-kf 4s ease-in-out infinite; }
@keyframes ac-cross-b-kf { 0%, 45% { opacity: 0; } 55%, 100% { opacity: 1; } }
+115 -17
View File
@@ -31,6 +31,66 @@ function el(tag, attrs = {}, ...children) {
node.append(...children);
return node;
}
// ── Shading helpers ────────────────────────────────────────────────────────
// Flat fills read as paper cutouts; every solid body (beans, flames, liquids)
// instead gets a radial gradient built from its base color — warm-white toward
// the light, warm-black away from it — so the shapes model light like objects.
const hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const mixHex = (hex, target, f) =>
`#${hexToRgb(hex)
.map((c, i) => Math.round(c + (target[i] - c) * f).toString(16).padStart(2, "0"))
.join("")}`;
const tint = (hex, f) => mixHex(hex, [255, 250, 240], f);
const shade = (hex, f) => mixHex(hex, [30, 18, 10], f);
let gradSeq = 0;
/** Per-instance radial gradient for a body color; returns [defNode, fillUrl].
* gradientUnits stay objectBoundingBox, so one def can shade many siblings. */
function radialFill(fill, opts = {}) {
const id = `ac-rg-${gradSeq++}`;
const node = el(
"radialGradient",
{ id, cx: opts.cx ?? "36%", cy: opts.cy ?? "30%", r: opts.r ?? "85%" },
el("stop", { offset: "0%", "stop-color": tint(fill, opts.tint ?? 0.3) }),
el("stop", { offset: "55%", "stop-color": fill }),
el("stop", { offset: "100%", "stop-color": shade(fill, opts.shade ?? 0.3) }),
);
return [node, `url(#${id})`];
}
/** Shared gradient/glow definitions every scene can reference by id.
* Appended once per slide render (the SVG is rebuilt each slide). */
export function sceneDefs() {
return el(
"defs",
{},
el(
"radialGradient",
{ id: "ac-ground", cx: "50%", cy: "50%", r: "50%" },
el("stop", { offset: "0%", "stop-color": "#1b1614", "stop-opacity": 0.16 }),
el("stop", { offset: "100%", "stop-color": "#1b1614", "stop-opacity": 0 }),
),
el(
"radialGradient",
{ id: "ac-halo", cx: "50%", cy: "50%", r: "50%" },
el("stop", { offset: "0%", "stop-color": "#e8892c", "stop-opacity": 0.38 }),
el("stop", { offset: "100%", "stop-color": "#e8892c", "stop-opacity": 0 }),
),
el(
"linearGradient",
{ id: "ac-flame-grad", x1: "0", y1: "0", x2: "0", y2: "1" },
el("stop", { offset: "0%", "stop-color": "#e8952f" }),
el("stop", { offset: "100%", "stop-color": "#9c3a10" }),
),
el(
"linearGradient",
{ id: "ac-flame-core", x1: "0", y1: "0", x2: "0", y2: "1" },
el("stop", { offset: "0%", "stop-color": "#f7e3a0" }),
el("stop", { offset: "100%", "stop-color": "#e0a83c" }),
),
);
}
const text = (x, y, str, size = 12.5, fill = C.ink2, anchor = "middle", weight = "400") =>
el("text", { x, y, fill, "font-size": size, "text-anchor": anchor, "font-weight": weight }, str);
const caption = (str, fill = C.ink2) => text(280, 286, str, 12.5, fill);
@@ -42,10 +102,16 @@ export const sceneFallbackText = (title) => text(280, 150, title, 16, C.ink2);
* INNER group — a CSS transform animation on the same element would silently override the
* SVG transform attribute and throw the bean to the canvas origin. */
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
const [grad, gradUrl] = radialFill(fill);
const inner = el("g", { class: opts.class ?? "" });
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: gradUrl }));
// Fill-shift animations (drying, Maillard, …) animate a flat overlay riding at partial
// opacity ABOVE the gradient, so the color changes while the 3-D shading survives.
if (/ac-(dry-shift|maillard-shift|grass-fade)/.test(opts.class ?? ""))
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill, class: "ac-tint" }));
inner.append(
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill }),
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.22)" }),
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.26)" }),
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
);
if (opts.crack)
inner.append(
@@ -57,7 +123,11 @@ function bean(x, y, scale = 1, fill = C.green, opts = {}) {
class: "ac-crackline",
}),
);
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, inner);
const outer = el("g", { transform: `translate(${x} ${y}) scale(${scale})` });
if (opts.shadow !== false)
outer.append(el("ellipse", { cx: 0, cy: 45, rx: 24, ry: 6, fill: "url(#ac-ground)" }));
outer.append(grad, inner);
return outer;
}
function steamWisps(x, y, n = 3) {
@@ -79,10 +149,12 @@ function steamWisps(x, y, n = 3) {
function flame(x, y, scale = 1) {
// Same outer/inner split as bean(): position on the outer, animation class on the inner.
// A soft radial halo behind the flame reads as radiant heat.
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
el("ellipse", { cx: 0, cy: -14, rx: 22, ry: 26, fill: "url(#ac-halo)" }),
el("g", { class: "ac-flame" },
el("path", { d: "M0 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 0 0 Z", fill: C.ember }),
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: C.yellow }),
el("path", { d: "M0 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 0 0 Z", fill: "url(#ac-flame-grad)" }),
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: "url(#ac-flame-core)" }),
),
);
}
@@ -95,16 +167,24 @@ function halfBean(x, y, scale = 1, opts = {}) {
const shapeD =
"M0,-58 C 28,-58 44,-32 44,0 C 44,32 28,58 0,58 C -28,58 -44,32 -44,0 C -44,-32 -28,-58 0,-58 Z";
const id = `hb-clip-${halfBeanClipSeq++}`;
const [grad, gradUrl] = radialFill(opts.shellFill ?? C.tan);
const shell = el("path", {
d: shapeD,
fill: opts.shellFill ?? C.tan,
fill: gradUrl,
stroke: opts.stroke ?? C.dark,
"stroke-width": opts.strokeWidth ?? 3,
});
const clip = el("clipPath", { id }, el("path", { d: shapeD }));
const content = el("g", { "clip-path": `url(#${id})` }, ...(opts.children ?? []));
const inner = el("g", { class: opts.class ?? "" }, shell, content);
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, clip, inner);
return el(
"g",
{ transform: `translate(${x} ${y}) scale(${scale})` },
el("ellipse", { cx: 0, cy: 66, rx: 44, ry: 8, fill: "url(#ac-ground)" }),
clip,
grad,
inner,
);
}
/** Small semi-circle dial gauge with a needle that sweeps toward the reading on a loop.
@@ -141,6 +221,8 @@ const arcCurve = (points, color, cls = "") =>
fill: "none",
stroke: color,
"stroke-width": 2.5,
"stroke-linecap": "round",
"stroke-linejoin": "round",
class: cls,
});
@@ -194,6 +276,7 @@ export const SCENES = {
* crack — synced to a stage timeline underneath. All keyframes share one 14s loop. */
beanJourney() {
const cx = 190, cy = 120;
const [jbGrad, jbUrl] = radialFill(C.green);
const seg = (x, w, color, label) => [
el("rect", { x, y: 236, width: w, height: 12, rx: 3, fill: color, opacity: 0.55 }),
text(x + w / 2, 264, label, 11.5, C.ink2),
@@ -205,11 +288,15 @@ export const SCENES = {
el("path", { d: `M${cx - 26 + i * 26} 214 v -12`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
),
text(cx, 228, "", 1),
// The bean itself: color, swell, crack all on the 14s journey clock
// The bean itself: color, swell, crack all on the 14s journey clock. The gradient
// base carries the shading; the animated flat overlay above it carries the color.
el("g", { transform: `translate(${cx} ${cy}) scale(2.15)` },
jbGrad,
el("g", { class: "ac-jb-grow" },
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: jbUrl }),
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: C.green, class: "ac-jb-color" }),
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.22)" }),
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.26)" }),
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
el("path", { d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32", fill: "none", stroke: C.paper, "stroke-width": 2.4, class: "ac-jb-crack" }),
),
),
@@ -232,9 +319,13 @@ export const SCENES = {
},
beanAnatomy() {
const cx = 180, cy = 145;
const [outerGrad, outerUrl] = radialFill(C.green);
const [innerGrad, innerUrl] = radialFill("#8ea06f", { tint: 0.2, shade: 0.18 });
return [
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: C.green, class: "ac-breathe" }),
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: "#8ea06f" }),
outerGrad,
innerGrad,
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: outerUrl, class: "ac-breathe" }),
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: innerUrl }),
el("path", { d: `M${cx} ${cy - 86} C ${cx + 27} ${cy - 36}, ${cx + 27} ${cy + 36}, ${cx} ${cy + 86} C ${cx - 18} ${cy + 36}, ${cx - 18} ${cy - 36}, ${cx} ${cy - 86} Z`, fill: "#6a7a50" }),
...[[cx - 55, cy - 45, 0], [cx + 48, cy - 30, 1], [cx - 30, cy + 50, 2], [cx + 40, cy + 42, 0], [cx + 4, cy - 4, 1]].map(([x, y, d]) =>
el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${d}` }),
@@ -278,8 +369,8 @@ export const SCENES = {
// Drum with tumbling beans
el("circle", { cx: 150, cy: 138, r: 76, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
el("g", { class: "ac-tumble" },
bean(126, 118, 0.5, C.green), bean(172, 132, 0.5, C.green),
bean(140, 162, 0.5, "#8ea06f"), bean(182, 168, 0.5, C.green)),
bean(126, 118, 0.5, C.green, { shadow: false }), bean(172, 132, 0.5, C.green, { shadow: false }),
bean(140, 162, 0.5, "#8ea06f", { shadow: false }), bean(182, 168, 0.5, C.green, { shadow: false })),
flame(150, 250, 1),
el("line", { x1: 254, y1: 120, x2: 205, y2: 132, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round" }),
el("circle", { cx: 205, cy: 132, r: 4, fill: C.ink }),
@@ -440,8 +531,11 @@ export const SCENES = {
// ── Brewing scenes ──
dissolve() {
const [grad, gradUrl] = radialFill(C.brown);
return [
el("circle", { cx: 150, cy: 140, r: 52, fill: C.brown }),
grad,
el("circle", { cx: 150, cy: 140, r: 52, fill: gradUrl }),
el("ellipse", { cx: 150, cy: 206, rx: 48, ry: 9, fill: "url(#ac-ground)" }),
text(150, 218, "ground coffee", 12, C.ink2),
...[0, 1, 2, 3, 4, 5].map((i) =>
el("circle", {
@@ -481,13 +575,17 @@ export const SCENES = {
];
},
grind() {
// One gradient def serves every particle (objectBoundingBox units shade each circle).
const [grindGrad, grindUrl] = radialFill(C.brown);
const pile = (x, label, sub, circles) => {
const g = el("g", {});
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: C.brown }));
g.append(el("ellipse", { cx: x, cy: 172, rx: 52, ry: 8, fill: "url(#ac-ground)" }));
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: grindUrl }));
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
return g;
};
return [
grindGrad,
text(280, 62, "the same 20 grams of coffee", 12.5),
pile(130, "coarse", "less surface · fast flow", [[-16, -10, 19], [16, -8, 19], [0, 18, 19]]),
pile(280, "medium", "the middle path", [[-24, -14, 10], [0, -18, 10], [24, -14, 10], [-14, 4, 10], [12, 4, 10], [-2, 22, 10], [22, 20, 10]]),
@@ -508,7 +606,7 @@ export const SCENES = {
el("circle", { cx: 280, cy: 190, r: 8, fill: C.ink }),
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
// Ratio: one bean to many drops
bean(420, 110, 0.62, C.brown),
bean(420, 110, 0.62, C.brown, { shadow: false }),
text(446, 116, "1 :", 15, C.ink, "start", "700"),
...[0, 1, 2].map((i) =>
el("path", { d: `M${480 + i * 18} 104 c 5 8 5 14 0 18 c -5 -4 -5 -10 0 -18 Z`, fill: C.water, class: `ac-pulse ac-delay-${i}` }),
@@ -1165,7 +1263,7 @@ export const SCENES = {
el("circle", { cx: 200, cy: 130, r: 74, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
...[0, 1, 2].map((i) => el("path", { d: `M${140 + i * 60} 210 v 30`, stroke: C.water, "stroke-width": 3, "stroke-linecap": "round", class: `ac-rise ac-delay-${i}` })),
text(200, 258, "airflow", 11, C.water, "middle"),
el("g", { class: "ac-tumble" }, bean(180, 116, 0.5, C.tan), bean(220, 132, 0.5, C.brown), bean(196, 150, 0.5, C.tan)),
el("g", { class: "ac-tumble" }, bean(180, 116, 0.5, C.tan, { shadow: false }), bean(220, 132, 0.5, C.brown, { shadow: false }), bean(196, 150, 0.5, C.tan, { shadow: false })),
text(400, 90, "too little →", 11.5, C.fail, "start", "700"),
text(400, 106, "smoke settles, ashy,", 10.5, C.ink2, "start"),
text(400, 120, "chaff fire risk", 10.5, C.ink2, "start"),
+2 -2
View File
@@ -1,7 +1,7 @@
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__";
import { SCENES, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
import { SCENES, sceneDefs, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
// ── Progress ───────────────────────────────────────────────────────────────
@@ -119,7 +119,7 @@ function renderSlide() {
// Rebuilt per slide so CSS animations restart with it.
const svg = $("player-scene");
svg.replaceChildren();
svg.replaceChildren(sceneDefs());
try {
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
} catch {
+6 -1
View File
@@ -131,7 +131,12 @@ async function loadPlans() {
try {
await api("/api/plans", {
method: "POST",
body: JSON.stringify({ plan: plan.plan }),
body: JSON.stringify({
plan: plan.plan,
// Carry the custom name across — a rename-only plan would otherwise
// duplicate as an indistinguishable "Untitled plan".
name: plan.name ? `${plan.name} (copy)` : "",
}),
});
showToast("Plan duplicated.");
loadPlans();
+2
View File
@@ -330,6 +330,8 @@ function renderUsers() {
showToast("User deleted.");
loadUsers();
loadMetrics();
loadPlans(); // their plans/audit rows are gone too
loadAudit();
} catch (error) {
showToast(error.message, "fail");
}
+33 -2
View File
@@ -21,6 +21,38 @@ export function initAlogPanel({
resultEl = document.getElementById("alog-result"),
actualInput = document.getElementById("actual-alog-file"),
actualResultEl = document.getElementById("actual-alog-result");
// A plan keeps its attached reference curve until explicitly removed — show what's
// attached (including on page load) with a Remove control, so adding a curve is
// never a one-way door.
function renderReference(summary) {
const ref = state.plan.reference;
if (!ref?.roast) {
resultEl.replaceChildren();
return;
}
const node = document.createElement("p");
node.textContent =
summary ??
`Attached: ${ref.roast.title || "reference curve"}${ref.roast.roastDate || "no date"}.`;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "ghost-btn small";
removeBtn.style.marginLeft = "8px";
removeBtn.textContent = "Remove reference curve";
removeBtn.addEventListener("click", () => {
state.plan.reference = null;
recompute();
setText(resultEl, "Reference curve removed.");
});
node.append(removeBtn);
resultEl.replaceChildren(node);
}
renderReference();
// Re-render on every drawer open: the user may have switched plans since init, and the
// summary must always describe the plan currently loaded.
document
.getElementById("nav-alog")
?.addEventListener("click", () => renderReference());
fileInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -135,8 +167,7 @@ export function initAlogPanel({
}
state.plan.reference = body;
recompute();
setText(
resultEl,
renderReference(
`${body.roast.title}${body.roast.roastDate || "no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct ?? "—"}%.`,
);
}
+6 -2
View File
@@ -147,9 +147,12 @@ function updateRatingPill() {
pill.classList.toggle("poor", Number(value) <= 4);
}
function renderBeanSelect() {
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 = select.value;
const current = keepId ?? select.value;
select.replaceChildren(
Object.assign(document.createElement("option"), {
value: "",
@@ -307,6 +310,7 @@ async function loadBrews() {
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
+50 -1
View File
@@ -355,10 +355,15 @@ function wireCupCount() {
renderTickRows();
renderDefectRows();
renderTotal();
renderSubtitle();
scheduleSave();
});
}
function renderSubtitle() {
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
}
// ── Flavor checklist ──
function renderFlavorChips() {
const chips = document.getElementById("flavor-chips");
@@ -578,7 +583,51 @@ async function initSessionView(user) {
data = body.session.data;
document.getElementById("cupping-title").textContent =
body.session.planTitle || "Cupping session";
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
renderSubtitle();
// Sessions created without a plan can attach one later (same PUT the autosave uses) —
// otherwise they're stuck titled "Untitled" forever.
if (!body.session.roastPlanId) {
const subtitle = document.getElementById("cupping-subtitle");
try {
const { plans } = await api("/api/plans");
if (plans.length) {
const wrap = document.createElement("span");
wrap.append(" · Plan: ");
const select = document.createElement("select");
select.className = "field-input sm";
select.style.display = "inline-block";
select.style.width = "auto";
select.append(
Object.assign(document.createElement("option"), {
value: "",
textContent: "— none —",
}),
...plans.map((p) =>
Object.assign(document.createElement("option"), {
value: p.id,
textContent: p.name || p.plan?.fields?.["0.1"] || "Untitled plan",
}),
),
);
select.addEventListener("change", async () => {
try {
await api(`/api/cupping/${sessionId}`, {
method: "PUT",
body: JSON.stringify({ data, roastPlanId: select.value || null }),
});
showToast("Plan attached.");
location.reload();
} catch (error) {
showToast(error.message, "fail");
}
});
wrap.append(select);
subtitle.after(wrap);
}
} catch {
/* attaching later is a convenience — the session still works without it */
}
}
wireCupCount();
renderScoreRows();
+8
View File
@@ -10,6 +10,9 @@ import {
const SVG_NS = "http://www.w3.org/2000/svg";
let gear = { brewers: [], grinders: [] };
let saveTimer = null;
// Never save over the server until we've successfully read its state: the PUT replaces both
// arrays wholesale, so saving after a failed load would wipe the user's stored equipment.
let loaded = false;
function silhouetteSvg(methodKey) {
const svg = document.createElementNS(SVG_NS, "svg");
@@ -29,6 +32,10 @@ function silhouetteSvg(methodKey) {
// Saves shortly after the last toggle rather than needing a Save button — an equipment page
// is set-and-forget, so every change should just stick.
function scheduleSave() {
if (!loaded) {
showToast("Equipment didn't load — reload the page before making changes.", "fail");
return;
}
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
@@ -144,6 +151,7 @@ async function init() {
if (!user) return;
try {
gear = (await api("/api/gear")).gear;
loaded = true;
} catch {
showToast("Could not load equipment.", "fail");
}
+29
View File
@@ -76,6 +76,35 @@ function renderRoasters() {
});
actions.append(makeDefault);
}
// Rename/model edits reuse the existing PUT — without this, a typo'd machine
// could only be deleted, which detaches every roast logged against it.
const editBtn = document.createElement("button");
editBtn.className = "ghost-btn small";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", async (event) => {
event.stopPropagation();
const name = prompt("Machine name:", roaster.name);
if (name == null) return;
if (!name.trim()) {
showToast("Name cannot be empty.", "fail");
return;
}
const model = prompt("Model (optional):", roaster.model ?? "");
if (model == null) return;
try {
await api(`/api/roasters/${roaster.id}`, {
method: "PUT",
body: JSON.stringify({ name: name.trim(), model: model.trim() }),
});
showToast("Roaster updated.");
await loadRoasters();
if (selectedId === roaster.id) renderReport();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
+14 -2
View File
@@ -86,7 +86,7 @@ async function loadRoasts() {
schedulePoll();
} catch {
document.getElementById("roasts-body").innerHTML =
`<tr><td colspan="7" class="empty-state">Could not load roasts.</td></tr>`;
`<tr><td colspan="6" class="empty-state">Could not load roasts.</td></tr>`;
}
}
@@ -406,7 +406,12 @@ async function openDetail(id, { keepScroll = false } = {}) {
`/api/roasts/${encodeURIComponent(id)}/download`;
document.getElementById("detail-download-updated").href =
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
renderAfterForm(detail);
// The 4s pending-evaluation poll re-runs openDetail; never let that background refresh
// overwrite after-roast fields the user is currently editing.
const afterForm = document.getElementById("after-form");
if (!keepScroll || !(afterFormDirty || afterForm.contains(document.activeElement)))
renderAfterForm(detail);
if (!keepScroll) afterFormDirty = false;
renderGraph(detail);
renderStats(detail);
renderEvaluation(detail);
@@ -421,6 +426,12 @@ const AFTER_FIELDS = [
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
];
// True while the after-roast form holds unsaved user edits (cleared on save/open).
let afterFormDirty = false;
document.getElementById("after-form").addEventListener("input", () => {
afterFormDirty = true;
});
function renderAfterForm(detail) {
const form = document.getElementById("after-form");
const after = detail.after ?? {};
@@ -449,6 +460,7 @@ document.getElementById("after-form").addEventListener("submit", async (event) =
method: "PUT",
body: JSON.stringify({ after }),
});
afterFormDirty = false;
showToast("After-roast saved — included in the updated .alog.");
} catch (error) {
showToast(error.message, "fail");
+24 -3
View File
@@ -173,7 +173,8 @@ export function createApp({
const parser =
req.method === "POST" && req.path === "/api/admin/backup/import"
? jsonBodyBackup
: req.method === "POST" && req.path === "/api/roasts"
: req.method === "POST" &&
(req.path === "/api/roasts" || req.path === "/api/alog")
? jsonBodyLarge
: jsonBody;
return parser(req, res, next);
@@ -2483,10 +2484,30 @@ export function createApp({
coerced.fault_cups,
coerced.cup_count,
);
// Optional plan reattachment (mirrors PUT /api/roasts): a session created
// without a plan would otherwise be stuck "Untitled" forever. Ownership of
// the target plan is enforced; absent field leaves the link unchanged.
let planClause = "";
const params = [coerced, total, req.params.id, req.user.id];
if (req.body.roastPlanId !== undefined) {
const planId = req.body.roastPlanId;
if (planId !== null) {
if (!UUID_RE.test(String(planId)))
return res.status(400).json({ ok: false, code: "bad_plan" });
const owned = await db.query(
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
[planId, req.user.id],
);
if (!owned.rowCount)
return res.status(404).json({ ok: false, code: "plan_not_found" });
}
params.push(planId);
planClause = ", roast_plan_id=$5";
}
const row = (
await db.query(
"UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *",
[coerced, total, req.params.id, req.user.id],
`UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now()${planClause} WHERE id=$3 AND user_id=$4 RETURNING *`,
params,
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
+50
View File
@@ -210,3 +210,53 @@ test("cupping: delete removes the session, and deleting the linked plan nulls ro
);
assert.equal((await agent.get(`/api/cupping/${session.id}`)).status, 404);
});
test("cupping: a plan can be attached after creation, with ownership enforced", async () => {
const { app } = await setup();
const owner = request.agent(app);
const attacker = request.agent(app);
const { csrf } = await signup(owner, "[email protected]");
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
const planId = await bootstrapPlan(owner, csrf);
const foreignPlanId = await bootstrapPlan(attacker, attackerCsrf);
const session = (
await owner.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
).body.session;
assert.equal(session.roastPlanId, null);
// A PUT without roastPlanId leaves the link untouched
const noTouch = await owner
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: session.data });
assert.equal(noTouch.status, 200);
assert.equal(noTouch.body.session.roastPlanId, null);
// Attaching my own plan works
const linked = await owner
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: session.data, roastPlanId: planId });
assert.equal(linked.status, 200);
assert.equal(linked.body.session.roastPlanId, planId);
// Someone else's plan is refused and the link stays intact
const cross = await owner
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: session.data, roastPlanId: foreignPlanId });
assert.equal(cross.status, 404);
assert.equal(
(await owner.get(`/api/cupping/${session.id}`)).body.session.roastPlanId,
planId,
);
// Explicit null detaches
const detached = await owner
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: session.data, roastPlanId: null });
assert.equal(detached.status, 200);
assert.equal(detached.body.session.roastPlanId, null);
});
+16
View File
@@ -326,3 +326,19 @@ test("roasts are private to their owner", async () => {
.send({ roastPlanId: plan.body.plan.id, filename: "x.alog", content: makeAlog() });
assert.equal(crossPlan.status, 404);
});
test("reference-curve parse endpoint accepts a real-sized (multi-MB) .alog", async () => {
const { agent } = await setup({}, { evaluateRoast: evaluationStub() });
const { csrf } = await signup(agent, "[email protected]");
// Real Artisan logs carry full telemetry arrays and legitimately exceed 1 MB —
// /api/alog must share the enlarged body cap that /api/roasts already has.
const doc = JSON.parse(makeAlog("Big log"));
doc.padding = "x".repeat(2 * 1024 * 1024);
const res = await agent
.post("/api/alog")
.set("x-csrf-token", csrf)
.send({ filename: "big.alog", content: JSON.stringify(doc) });
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.roast.title, "Big log");
});