Removable reference curves, audit fixes across pages, shaded Academy art
Test and deploy / test-and-deploy (push) Successful in 1m8s
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:
+13
-4
@@ -3141,10 +3141,16 @@ select.f {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.academy-stage {
|
.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: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 8px;
|
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 {
|
.academy-stage svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -3229,7 +3235,10 @@ select.f {
|
|||||||
@keyframes ac-pour-kf { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
@keyframes ac-pour-kf { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||||
.ac-pourline { animation: ac-pour-kf 1.6s ease-in-out infinite; }
|
.ac-pourline { animation: ac-pour-kf 1.6s ease-in-out infinite; }
|
||||||
@keyframes ac-maillard-kf { 0% { fill: #d9ba5a; } 100% { fill: #8a5a30; } }
|
@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); } }
|
@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; }
|
.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); } }
|
@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); } }
|
@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; }
|
.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; } }
|
@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); } }
|
@keyframes ac-needle-kf { 0%, 100% { transform: rotate(-52deg); } 50% { transform: rotate(52deg); } }
|
||||||
.ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; }
|
.ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; }
|
||||||
@keyframes ac-pulse-soft-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } }
|
@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; }
|
.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; } }
|
@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; } }
|
@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; }
|
.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; } }
|
@keyframes ac-cross-b-kf { 0%, 45% { opacity: 0; } 55%, 100% { opacity: 1; } }
|
||||||
|
|||||||
+115
-17
@@ -31,6 +31,66 @@ function el(tag, attrs = {}, ...children) {
|
|||||||
node.append(...children);
|
node.append(...children);
|
||||||
return node;
|
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") =>
|
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);
|
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);
|
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
|
* 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. */
|
* SVG transform attribute and throw the bean to the canvas origin. */
|
||||||
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
||||||
|
const [grad, gradUrl] = radialFill(fill);
|
||||||
const inner = el("g", { class: opts.class ?? "" });
|
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(
|
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.26)" }),
|
||||||
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("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)
|
if (opts.crack)
|
||||||
inner.append(
|
inner.append(
|
||||||
@@ -57,7 +123,11 @@ function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
|||||||
class: "ac-crackline",
|
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) {
|
function steamWisps(x, y, n = 3) {
|
||||||
@@ -79,10 +149,12 @@ function steamWisps(x, y, n = 3) {
|
|||||||
|
|
||||||
function flame(x, y, scale = 1) {
|
function flame(x, y, scale = 1) {
|
||||||
// Same outer/inner split as bean(): position on the outer, animation class on the inner.
|
// 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})` },
|
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("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 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: C.yellow }),
|
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 =
|
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";
|
"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 id = `hb-clip-${halfBeanClipSeq++}`;
|
||||||
|
const [grad, gradUrl] = radialFill(opts.shellFill ?? C.tan);
|
||||||
const shell = el("path", {
|
const shell = el("path", {
|
||||||
d: shapeD,
|
d: shapeD,
|
||||||
fill: opts.shellFill ?? C.tan,
|
fill: gradUrl,
|
||||||
stroke: opts.stroke ?? C.dark,
|
stroke: opts.stroke ?? C.dark,
|
||||||
"stroke-width": opts.strokeWidth ?? 3,
|
"stroke-width": opts.strokeWidth ?? 3,
|
||||||
});
|
});
|
||||||
const clip = el("clipPath", { id }, el("path", { d: shapeD }));
|
const clip = el("clipPath", { id }, el("path", { d: shapeD }));
|
||||||
const content = el("g", { "clip-path": `url(#${id})` }, ...(opts.children ?? []));
|
const content = el("g", { "clip-path": `url(#${id})` }, ...(opts.children ?? []));
|
||||||
const inner = el("g", { class: opts.class ?? "" }, shell, content);
|
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.
|
/** 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",
|
fill: "none",
|
||||||
stroke: color,
|
stroke: color,
|
||||||
"stroke-width": 2.5,
|
"stroke-width": 2.5,
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
class: cls,
|
class: cls,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,6 +276,7 @@ export const SCENES = {
|
|||||||
* crack — synced to a stage timeline underneath. All keyframes share one 14s loop. */
|
* crack — synced to a stage timeline underneath. All keyframes share one 14s loop. */
|
||||||
beanJourney() {
|
beanJourney() {
|
||||||
const cx = 190, cy = 120;
|
const cx = 190, cy = 120;
|
||||||
|
const [jbGrad, jbUrl] = radialFill(C.green);
|
||||||
const seg = (x, w, color, label) => [
|
const seg = (x, w, color, label) => [
|
||||||
el("rect", { x, y: 236, width: w, height: 12, rx: 3, fill: color, opacity: 0.55 }),
|
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),
|
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}` }),
|
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),
|
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)` },
|
el("g", { transform: `translate(${cx} ${cy}) scale(2.15)` },
|
||||||
|
jbGrad,
|
||||||
el("g", { class: "ac-jb-grow" },
|
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("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" }),
|
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() {
|
beanAnatomy() {
|
||||||
const cx = 180, cy = 145;
|
const cx = 180, cy = 145;
|
||||||
|
const [outerGrad, outerUrl] = radialFill(C.green);
|
||||||
|
const [innerGrad, innerUrl] = radialFill("#8ea06f", { tint: 0.2, shade: 0.18 });
|
||||||
return [
|
return [
|
||||||
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: C.green, class: "ac-breathe" }),
|
outerGrad,
|
||||||
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: "#8ea06f" }),
|
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" }),
|
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]) =>
|
...[[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}` }),
|
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
|
// Drum with tumbling beans
|
||||||
el("circle", { cx: 150, cy: 138, r: 76, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
el("circle", { cx: 150, cy: 138, r: 76, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
||||||
el("g", { class: "ac-tumble" },
|
el("g", { class: "ac-tumble" },
|
||||||
bean(126, 118, 0.5, C.green), bean(172, 132, 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"), bean(182, 168, 0.5, C.green)),
|
bean(140, 162, 0.5, "#8ea06f", { shadow: false }), bean(182, 168, 0.5, C.green, { shadow: false })),
|
||||||
flame(150, 250, 1),
|
flame(150, 250, 1),
|
||||||
el("line", { x1: 254, y1: 120, x2: 205, y2: 132, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round" }),
|
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 }),
|
el("circle", { cx: 205, cy: 132, r: 4, fill: C.ink }),
|
||||||
@@ -440,8 +531,11 @@ export const SCENES = {
|
|||||||
|
|
||||||
// ── Brewing scenes ──
|
// ── Brewing scenes ──
|
||||||
dissolve() {
|
dissolve() {
|
||||||
|
const [grad, gradUrl] = radialFill(C.brown);
|
||||||
return [
|
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),
|
text(150, 218, "ground coffee", 12, C.ink2),
|
||||||
...[0, 1, 2, 3, 4, 5].map((i) =>
|
...[0, 1, 2, 3, 4, 5].map((i) =>
|
||||||
el("circle", {
|
el("circle", {
|
||||||
@@ -481,13 +575,17 @@ export const SCENES = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
grind() {
|
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 pile = (x, label, sub, circles) => {
|
||||||
const g = el("g", {});
|
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));
|
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
|
||||||
return g;
|
return g;
|
||||||
};
|
};
|
||||||
return [
|
return [
|
||||||
|
grindGrad,
|
||||||
text(280, 62, "the same 20 grams of coffee", 12.5),
|
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(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]]),
|
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 }),
|
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"),
|
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
|
||||||
// Ratio: one bean to many drops
|
// 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"),
|
text(446, 116, "1 :", 15, C.ink, "start", "700"),
|
||||||
...[0, 1, 2].map((i) =>
|
...[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}` }),
|
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 }),
|
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}` })),
|
...[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"),
|
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, 90, "too little →", 11.5, C.fail, "start", "700"),
|
||||||
text(400, 106, "smoke settles, ashy,", 10.5, C.ink2, "start"),
|
text(400, 106, "smoke settles, ashy,", 10.5, C.ink2, "start"),
|
||||||
text(400, 120, "chaff fire risk", 10.5, C.ink2, "start"),
|
text(400, 120, "chaff fire risk", 10.5, C.ink2, "start"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||||
import { ACADEMY_TRACKS } from "./academy-content.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 ───────────────────────────────────────────────────────────────
|
// ── Progress ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ function renderSlide() {
|
|||||||
|
|
||||||
// Rebuilt per slide so CSS animations restart with it.
|
// Rebuilt per slide so CSS animations restart with it.
|
||||||
const svg = $("player-scene");
|
const svg = $("player-scene");
|
||||||
svg.replaceChildren();
|
svg.replaceChildren(sceneDefs());
|
||||||
try {
|
try {
|
||||||
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -131,7 +131,12 @@ async function loadPlans() {
|
|||||||
try {
|
try {
|
||||||
await api("/api/plans", {
|
await api("/api/plans", {
|
||||||
method: "POST",
|
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.");
|
showToast("Plan duplicated.");
|
||||||
loadPlans();
|
loadPlans();
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ function renderUsers() {
|
|||||||
showToast("User deleted.");
|
showToast("User deleted.");
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadMetrics();
|
loadMetrics();
|
||||||
|
loadPlans(); // their plans/audit rows are gone too
|
||||||
|
loadAudit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, "fail");
|
showToast(error.message, "fail");
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -21,6 +21,38 @@ export function initAlogPanel({
|
|||||||
resultEl = document.getElementById("alog-result"),
|
resultEl = document.getElementById("alog-result"),
|
||||||
actualInput = document.getElementById("actual-alog-file"),
|
actualInput = document.getElementById("actual-alog-file"),
|
||||||
actualResultEl = document.getElementById("actual-alog-result");
|
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) => {
|
fileInput.addEventListener("change", async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -135,8 +167,7 @@ export function initAlogPanel({
|
|||||||
}
|
}
|
||||||
state.plan.reference = body;
|
state.plan.reference = body;
|
||||||
recompute();
|
recompute();
|
||||||
setText(
|
renderReference(
|
||||||
resultEl,
|
|
||||||
`${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 ?? "—"}%.`,
|
`${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
@@ -147,9 +147,12 @@ function updateRatingPill() {
|
|||||||
pill.classList.toggle("poor", Number(value) <= 4);
|
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 select = document.getElementById("brew-bean-select");
|
||||||
const current = select.value;
|
const current = keepId ?? select.value;
|
||||||
select.replaceChildren(
|
select.replaceChildren(
|
||||||
Object.assign(document.createElement("option"), {
|
Object.assign(document.createElement("option"), {
|
||||||
value: "",
|
value: "",
|
||||||
@@ -307,6 +310,7 @@ async function loadBrews() {
|
|||||||
function startEdit(brew) {
|
function startEdit(brew) {
|
||||||
editingId = brew.id;
|
editingId = brew.id;
|
||||||
const form = document.getElementById("brew-form");
|
const form = document.getElementById("brew-form");
|
||||||
|
renderBeanSelect(brew.beanId ?? ""); // the edited brew's bean may be archived
|
||||||
form.beanId.value = brew.beanId ?? "";
|
form.beanId.value = brew.beanId ?? "";
|
||||||
selectedMethod = brew.method;
|
selectedMethod = brew.method;
|
||||||
renderBrewerPicker(); // the edited brew's method may be outside the owned-gear filter
|
renderBrewerPicker(); // the edited brew's method may be outside the owned-gear filter
|
||||||
|
|||||||
+50
-1
@@ -355,10 +355,15 @@ function wireCupCount() {
|
|||||||
renderTickRows();
|
renderTickRows();
|
||||||
renderDefectRows();
|
renderDefectRows();
|
||||||
renderTotal();
|
renderTotal();
|
||||||
|
renderSubtitle();
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSubtitle() {
|
||||||
|
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flavor checklist ──
|
// ── Flavor checklist ──
|
||||||
function renderFlavorChips() {
|
function renderFlavorChips() {
|
||||||
const chips = document.getElementById("flavor-chips");
|
const chips = document.getElementById("flavor-chips");
|
||||||
@@ -578,7 +583,51 @@ async function initSessionView(user) {
|
|||||||
data = body.session.data;
|
data = body.session.data;
|
||||||
document.getElementById("cupping-title").textContent =
|
document.getElementById("cupping-title").textContent =
|
||||||
body.session.planTitle || "Cupping session";
|
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();
|
wireCupCount();
|
||||||
renderScoreRows();
|
renderScoreRows();
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
let gear = { brewers: [], grinders: [] };
|
let gear = { brewers: [], grinders: [] };
|
||||||
let saveTimer = null;
|
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) {
|
function silhouetteSvg(methodKey) {
|
||||||
const svg = document.createElementNS(SVG_NS, "svg");
|
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
|
// 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.
|
// is set-and-forget, so every change should just stick.
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
|
if (!loaded) {
|
||||||
|
showToast("Equipment didn't load — reload the page before making changes.", "fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(async () => {
|
saveTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +151,7 @@ async function init() {
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
gear = (await api("/api/gear")).gear;
|
gear = (await api("/api/gear")).gear;
|
||||||
|
loaded = true;
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Could not load equipment.", "fail");
|
showToast("Could not load equipment.", "fail");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,35 @@ function renderRoasters() {
|
|||||||
});
|
});
|
||||||
actions.append(makeDefault);
|
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");
|
const deleteBtn = document.createElement("button");
|
||||||
deleteBtn.className = "ghost-btn small";
|
deleteBtn.className = "ghost-btn small";
|
||||||
deleteBtn.type = "button";
|
deleteBtn.type = "button";
|
||||||
|
|||||||
+13
-1
@@ -86,7 +86,7 @@ async function loadRoasts() {
|
|||||||
schedulePoll();
|
schedulePoll();
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById("roasts-body").innerHTML =
|
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`;
|
`/api/roasts/${encodeURIComponent(id)}/download`;
|
||||||
document.getElementById("detail-download-updated").href =
|
document.getElementById("detail-download-updated").href =
|
||||||
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
|
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
|
||||||
|
// 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);
|
renderAfterForm(detail);
|
||||||
|
if (!keepScroll) afterFormDirty = false;
|
||||||
renderGraph(detail);
|
renderGraph(detail);
|
||||||
renderStats(detail);
|
renderStats(detail);
|
||||||
renderEvaluation(detail);
|
renderEvaluation(detail);
|
||||||
@@ -421,6 +426,12 @@ const AFTER_FIELDS = [
|
|||||||
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
|
"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) {
|
function renderAfterForm(detail) {
|
||||||
const form = document.getElementById("after-form");
|
const form = document.getElementById("after-form");
|
||||||
const after = detail.after ?? {};
|
const after = detail.after ?? {};
|
||||||
@@ -449,6 +460,7 @@ document.getElementById("after-form").addEventListener("submit", async (event) =
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ after }),
|
body: JSON.stringify({ after }),
|
||||||
});
|
});
|
||||||
|
afterFormDirty = false;
|
||||||
showToast("After-roast saved — included in the updated .alog.");
|
showToast("After-roast saved — included in the updated .alog.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, "fail");
|
showToast(error.message, "fail");
|
||||||
|
|||||||
+24
-3
@@ -173,7 +173,8 @@ export function createApp({
|
|||||||
const parser =
|
const parser =
|
||||||
req.method === "POST" && req.path === "/api/admin/backup/import"
|
req.method === "POST" && req.path === "/api/admin/backup/import"
|
||||||
? jsonBodyBackup
|
? jsonBodyBackup
|
||||||
: req.method === "POST" && req.path === "/api/roasts"
|
: req.method === "POST" &&
|
||||||
|
(req.path === "/api/roasts" || req.path === "/api/alog")
|
||||||
? jsonBodyLarge
|
? jsonBodyLarge
|
||||||
: jsonBody;
|
: jsonBody;
|
||||||
return parser(req, res, next);
|
return parser(req, res, next);
|
||||||
@@ -2483,10 +2484,30 @@ export function createApp({
|
|||||||
coerced.fault_cups,
|
coerced.fault_cups,
|
||||||
coerced.cup_count,
|
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 = (
|
const row = (
|
||||||
await db.query(
|
await db.query(
|
||||||
"UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *",
|
`UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now()${planClause} WHERE id=$3 AND user_id=$4 RETURNING *`,
|
||||||
[coerced, total, req.params.id, req.user.id],
|
params,
|
||||||
)
|
)
|
||||||
).rows[0];
|
).rows[0];
|
||||||
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
||||||
|
|||||||
@@ -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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -326,3 +326,19 @@ test("roasts are private to their owner", async () => {
|
|||||||
.send({ roastPlanId: plan.body.plan.id, filename: "x.alog", content: makeAlog() });
|
.send({ roastPlanId: plan.body.plan.id, filename: "x.alog", content: makeAlog() });
|
||||||
assert.equal(crossPlan.status, 404);
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user