From 385474ecef1e6edf5f9d85c2d66b77e01d66b700 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Tue, 11 Aug 2026 16:59:28 -0400 Subject: [PATCH] Removable reference curves, audit fixes across pages, shaded Academy art 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 --- public/app.css | 17 +++-- public/js/academy-scenes.js | 132 +++++++++++++++++++++++++++++++----- public/js/academy.js | 4 +- public/js/account.js | 7 +- public/js/admin.js | 2 + public/js/alog-ui.js | 35 +++++++++- public/js/brews.js | 8 ++- public/js/cupping.js | 51 +++++++++++++- public/js/gear.js | 8 +++ public/js/roasters.js | 29 ++++++++ public/js/roasts.js | 16 ++++- server/app.js | 27 +++++++- test/cupping.test.js | 50 ++++++++++++++ test/roasts.test.js | 16 +++++ 14 files changed, 368 insertions(+), 34 deletions(-) diff --git a/public/app.css b/public/app.css index a6774d4..c3184a2 100644 --- a/public/app.css +++ b/public/app.css @@ -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; } } diff --git a/public/js/academy-scenes.js b/public/js/academy-scenes.js index 95e1f84..9b9e0fd 100644 --- a/public/js/academy-scenes.js +++ b/public/js/academy-scenes.js @@ -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"), diff --git a/public/js/academy.js b/public/js/academy.js index 2913f56..dc0928e 100644 --- a/public/js/academy.js +++ b/public/js/academy.js @@ -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 { diff --git a/public/js/account.js b/public/js/account.js index 284f1e6..f61fbee 100644 --- a/public/js/account.js +++ b/public/js/account.js @@ -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(); diff --git a/public/js/admin.js b/public/js/admin.js index 3d1aeb1..2d39220 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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"); } diff --git a/public/js/alog-ui.js b/public/js/alog-ui.js index cd7bfd9..a3bab8d 100644 --- a/public/js/alog-ui.js +++ b/public/js/alog-ui.js @@ -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 ?? "—"}%.`, ); } diff --git a/public/js/brews.js b/public/js/brews.js index a3321c8..ba0fbd1 100644 --- a/public/js/brews.js +++ b/public/js/brews.js @@ -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 diff --git a/public/js/cupping.js b/public/js/cupping.js index 691316b..6974056 100644 --- a/public/js/cupping.js +++ b/public/js/cupping.js @@ -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(); diff --git a/public/js/gear.js b/public/js/gear.js index 1f7fb4e..60a26c2 100644 --- a/public/js/gear.js +++ b/public/js/gear.js @@ -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"); } diff --git a/public/js/roasters.js b/public/js/roasters.js index 4d9f92a..0471d91 100644 --- a/public/js/roasters.js +++ b/public/js/roasters.js @@ -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"; diff --git a/public/js/roasts.js b/public/js/roasts.js index 89e310d..3fb7478 100644 --- a/public/js/roasts.js +++ b/public/js/roasts.js @@ -86,7 +86,7 @@ async function loadRoasts() { schedulePoll(); } catch { document.getElementById("roasts-body").innerHTML = - `Could not load roasts.`; + `Could not load roasts.`; } } @@ -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"); diff --git a/server/app.js b/server/app.js index 12f5025..e525888 100644 --- a/server/app.js +++ b/server/app.js @@ -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" }); diff --git a/test/cupping.test.js b/test/cupping.test.js index 838a7e0..4786e55 100644 --- a/test/cupping.test.js +++ b/test/cupping.test.js @@ -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, "late-link@example.com"); + const { csrf: attackerCsrf } = await signup(attacker, "other@example.com"); + 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); +}); diff --git a/test/roasts.test.js b/test/roasts.test.js index c97af04..8501018 100644 --- a/test/roasts.test.js +++ b/test/roasts.test.js @@ -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, "bigfile@example.com"); + // 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"); +});