Academy v4: 3 research-backed lessons (30 slides + audio); new bean logo; custom plan/lot names
Test and deploy / test-and-deploy (push) Successful in 59s

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 14:30:42 -04:00
co-authored by Claude Fable 5
parent ec078116b4
commit 8b80c3fa6f
65 changed files with 1315 additions and 78 deletions
+5
View File
@@ -0,0 +1,5 @@
-- Additive only. Custom display names for roast plans and green bean lots, layered on top of
-- (not replacing) worksheet field 0.1 "Coffee / lot" — COALESCE(NULLIF(name,''), field 0.1) is
-- the fallback everywhere a plan title is shown.
ALTER TABLE roast_plans ADD COLUMN name text NOT NULL DEFAULT '';
ALTER TABLE green_bean_lots ADD COLUMN name text NOT NULL DEFAULT '';
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+63 -9
View File
@@ -101,8 +101,9 @@ body:has(#plan-form) {
flex: 0 0 auto;
}
.brand-mark {
font-size: 22px;
color: var(--ember);
width: 20px;
height: auto;
display: block;
line-height: 1;
}
.brand-text h1 {
@@ -1781,8 +1782,9 @@ body.show-fids .field[data-fid]::after {
white-space: nowrap;
}
.side-nav-brand .brand-mark {
color: var(--ember-soft, #fbefe6);
font-size: 22px;
width: 20px;
height: auto;
display: block;
}
.side-nav-brand span.brand-name {
font-weight: 700;
@@ -2265,8 +2267,9 @@ body.show-fids .field[data-fid]::after {
margin-bottom: 2px;
}
.auth-card .auth-brand .brand-mark {
color: var(--ember);
font-size: 22px;
width: 20px;
height: auto;
display: block;
}
.auth-card h1 {
font-size: 19px;
@@ -2347,8 +2350,9 @@ body.show-fids .field[data-fid]::after {
font-size: 15px;
}
.marketing-header .brand .brand-mark {
color: var(--ember);
font-size: 20px;
width: 18px;
height: auto;
display: block;
}
.marketing-header nav {
display: flex;
@@ -2592,7 +2596,9 @@ body.show-fids .field[data-fid]::after {
order: 0;
}
.brand-mark {
font-size: 24px;
width: 22px;
height: auto;
display: block;
}
.brand-text h1 {
font-size: 16px;
@@ -3301,6 +3307,54 @@ select.f {
}
.ac-jb-marker { animation: ac-jb-marker-kf 14s linear infinite; }
/* Academy v4: inside-the-bean / senses / gone-wrong scene animations */
@keyframes ac-plug-kf {
0% { transform: translate(0, 0); opacity: 0.9; }
60%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
}
.ac-plug { animation: ac-plug-kf 3s ease-out infinite; }
@keyframes ac-wobble-kf {
0%, 100% { transform: scale(1, 1) rotate(0deg); }
25% { transform: scale(1.05, 0.95) rotate(-2deg); }
75% { transform: scale(0.95, 1.05) rotate(2deg); }
}
.ac-wobble { animation: ac-wobble-kf 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes ac-shrink-kf { 0% { transform: scale(1); } 100% { transform: scale(0.35); } }
.ac-shrink { animation: ac-shrink-kf 6s ease-in-out infinite alternate; transform-box: fill-box; transform-origin: center; }
@keyframes ac-gauge-kf {
0% { transform: rotate(-58deg); }
55%, 100% { transform: rotate(50deg); }
}
.ac-gauge-needle { animation: ac-gauge-kf 3.2s ease-out infinite; }
@keyframes ac-grow-foam-kf {
0% { transform: scale(0.8); }
60%, 100% { transform: scale(1); }
}
.ac-grow-foam { animation: ac-grow-foam-kf 4s ease-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes ac-mortar-fade-kf { 0%, 20% { opacity: 1; } 60% { opacity: 0.25; } 100% { opacity: 1; } }
.ac-mortar-fade { animation: ac-mortar-fade-kf 4.5s ease-in-out infinite; }
@keyframes ac-rebar-snap-kf { 0%, 70% { opacity: 0; } 78%, 100% { opacity: 1; } }
.ac-rebar-snap { animation: ac-rebar-snap-kf 6s linear infinite; }
@keyframes ac-burst-kf {
0% { transform: translateY(6px) scale(0.6); opacity: 0; }
30% { opacity: 1; }
100% { transform: translateY(-30px) scale(1.1); opacity: 0; }
}
.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; }
@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; } }
.ac-cross-b { animation: ac-cross-b-kf 4s ease-in-out infinite; }
@keyframes ac-marker-cross-kf {
0%, 100% { transform: translateX(-30px); }
50% { transform: translateX(30px); }
}
.ac-marker-cross { animation: ac-marker-cross-kf 4s ease-in-out infinite; }
@keyframes ac-row-glow-kf { 0%, 100% { opacity: 0.15; } 15%, 30% { opacity: 0.85; } }
.ac-row-glow { animation: ac-row-glow-kf 6s ease-in-out infinite; }
/* ── Flavor wheel (cupping) ───────────────────────────────────────────── */
.flavor-wheel-wrap {
max-width: 480px;
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="110 78 292 356">
<!-- Standalone Roast Planner bean mark (no tile) for nav / auth / marketing lockups. -->
<defs>
<clipPath id="bean-clip">
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"/>
</clipPath>
</defs>
<g transform="rotate(-20 256 256)">
<g clip-path="url(#bean-clip)">
<rect x="120" y="80" width="136" height="352" fill="#7A8C5E"/>
<rect x="256" y="80" width="136" height="352" fill="#A8481A"/>
</g>
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"
fill="none" stroke="#54371F" stroke-width="14"/>
<path d="M256 122 C 296 190 216 322 256 390" fill="none" stroke="#FBF8F4" stroke-width="26" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 900 B

+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+1 -1
View File
@@ -12,7 +12,7 @@
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<strong>Roast Planner</strong>
</div>
<h1>Forgot your password?</h1>
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+17 -5
View File
@@ -1,7 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#FBF8F4"/>
<circle cx="256" cy="256" r="176" fill="#A8481A"/>
<path fill="#FBF8F4" d="M166 166h180v45h-135v90h116v45H211v-90h-45z"/>
<path fill="#FBEFE6" d="M256 118c43 54 71 91 71 133a71 71 0 1 1-142 0c0-42 28-79 71-133Z" opacity=".9"/>
<path fill="#A8481A" d="M233 189c-13 22-20 42-20 60a43 43 0 1 0 86 0c0-18-7-38-20-60 3 10 4 19 4 28 0 23-12 42-27 42s-27-19-27-42c0-9 1-18 4-28Z"/>
<!-- Roast Planner mark: one bean, raw → roasted. Green half is the lot on the shelf,
ember half is the finished roast; the crease is the journey between them. -->
<defs>
<clipPath id="bean-clip">
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"/>
</clipPath>
</defs>
<rect width="512" height="512" rx="120" fill="#FBF8F4"/>
<g transform="rotate(-20 256 256)">
<g clip-path="url(#bean-clip)">
<rect x="120" y="80" width="136" height="352" fill="#7A8C5E"/>
<rect x="256" y="80" width="136" height="352" fill="#A8481A"/>
</g>
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"
fill="none" stroke="#54371F" stroke-width="14"/>
<path d="M256 122 C 296 190 216 322 256 390" fill="none" stroke="#FBF8F4" stroke-width="26" stroke-linecap="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -17,7 +17,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+8 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
@@ -111,6 +111,13 @@
<form id="lot-form">
<input type="hidden" name="id" />
<div class="field-grid">
<label class="field"
><span class="field-label">Name</span
><input
class="field-input"
name="name"
placeholder="e.g. Spring Ethiopia — defaults to origin"
/></label>
<label class="field"
><span class="field-label">Origin *</span
><input
+236 -1
View File
@@ -13,7 +13,7 @@ export const ACADEMY_TRACKS = [
key: "roasting",
name: "Roasting",
blurb:
"One continuous journey — a green seed to a finished roast, with the physics at every step.",
"From one continuous journey to the physics inside the bean — what you'll smell, see, and hear, and how roasts go wrong.",
lessons: [
{
id: "roast-journey",
@@ -113,6 +113,241 @@ export const ACADEMY_TRACKS = [
},
],
},
{
id: "inside-bean",
title: "Inside the bean",
minutes: 8,
slides: [
{
id: "ib-cells",
title: "A few million tiny boilers",
scene: ["ibCells", {}],
transcript:
"A green bean isn't a sponge — it's a dense, glassy block honeycombed with a few million microscopic cells. Each one is packed with oil, sugar, protein, and water. Roasting is what happens when every one of those cells becomes a tiny boiler.",
},
{
id: "ib-seal",
title: "The bean seals itself shut",
scene: ["ibSeal", {}],
transcript:
"Cell walls have natural pores that would leak steam harmlessly away. But early in the roast, heat drives the cell's own oil outward, plugging those pores shut. The bean seals itself — and that is why pressure can build at all.",
},
{
id: "ib-glass",
title: "Glass to rubber, and back",
scene: ["ibGlass", {}],
transcript:
"Within the first minute the warm, moist bean crosses its glass transition — from hard candy to warm caramel. Everything that follows happens in a soft, stretchable material. When the finished bean dries out and cools, it turns glassy again, locking its puffed shape in place forever.",
},
{
id: "ib-front",
title: "The retreating shoreline",
scene: ["ibFront", {}],
transcript:
"Water doesn't leave the bean evenly — it retreats from the outside in, like a shoreline. Ahead of the front the core stays wet and cool, pinned near boiling. Behind it the dry shell races ahead, browning first. That gap between outside and inside is what the rest of the roast must close.",
},
{
id: "ib-pressure",
title: "Twice espresso pressure",
scene: ["ibPressure", {}],
transcript:
"As long as liquid water remains, the steam pressure inside each cell is locked to the temperature — around fifteen bar as the bean nears first crack. That's twice what an espresso machine pushes, inside something the size of a pea. No one has measured it directly; the steam tables set the ceiling.",
},
{
id: "ib-crack",
title: "Why it finally cracks",
scene: ["ibCrack", {}],
transcript:
"First crack is a structural failure: the rubbery, pressurized interior strains against the stiffer shell that dried first, until the shell gives way. It isn't one explosion — it's millions of tiny ones. And listen closely: fewer than one bean in ten actually pops out loud.",
},
{
id: "ib-foam",
title: "The bean becomes a foam",
scene: ["ibFoam", {}],
transcript:
"By the drop, the bean has swollen by half or more and nearly half its volume is air — it's become a foam. Surprisingly, that expansion is steady across the whole roast, not a single pop at first crack. The crack is the sound of walls failing, not the moment of inflation.",
},
{
id: "ib-mortar",
title: "Mortar and rebar",
scene: ["ibMortar", {}],
transcript:
"The cell walls are mortar and rebar. Roasting dissolves the soft mortar — the gums and sugars, up to sixty percent of them — which is what lets the bean stretch without falling apart. The cellulose rebar barely reacts. Push on toward very dark, and even the rebar starts to char and snap: that's second crack, a brittle fracture, not a steam pop.",
},
{
id: "ib-co2",
title: "Coffee is carbonated",
scene: ["ibCo2", {}],
transcript:
"Roasting brews carbon dioxide inside the bean — up to eight liters per kilo, trapped in pores a few nanometers wide. Whole beans need weeks to let it go; grinding rips half of it out in seconds. That gas is the bloom in your pour-over and the crema on your espresso.",
tts: "Roasting brews carbon dioxide inside the bean — up to eight liters per kilo, trapped in pores a few nano-meters wide. Whole beans need weeks to let it go; grinding rips half of it out in seconds. That gas is the bloom in your pour-over and the crema on your espresso.",
},
{
id: "ib-color",
title: "Same color, different coffee",
scene: ["ibColor", {}],
transcript:
"Two roasts can land on exactly the same color and still be different coffees — a fast, hot roast ends puffier, lighter, and chemically distinct from a slow one at the same shade. Color tells you where the roast ended. The curve tells you how it got there. Log both.",
},
],
},
{
id: "senses",
title: "Roasting by the senses",
minutes: 8,
slides: [
{
id: "sn-instruments",
title: "Your best instruments",
scene: ["snInstruments", {}],
transcript:
"The same first crack can read three-ninety on one machine and four-ten on another — probes measure their own placement as much as the bean. Your nose, eyes, and ears are the instruments that transfer between machines. This lesson trains them.",
tts: "The same first crack can read three ninety on one machine and four ten on another — probes measure their own placement as much as the bean. Your nose, eyes, and ears are the instruments that transfer between machines. This lesson trains them.",
},
{
id: "sn-grass",
title: "Grass, then wet hay",
scene: ["snGrass", {}],
transcript:
"For the first minutes the trier smells like a lawn, then a barn: cut grass, wet hay, damp grain. The air above the beans is steam, not smoke — humid and heavy. Nothing is being created yet; the bean is only letting go of water.",
},
{
id: "sn-hinge",
title: "The hinge: hay becomes bread",
scene: ["snHinge", {}],
transcript:
"Somewhere around three-thirty on the probe, in the space of ten seconds, hay becomes baking biscuits. That scent change is the end of drying — the water is out and browning can begin. Green coffee colors vary too much to trust your eyes here; experienced roasters call this moment by nose.",
tts: "Somewhere around three thirty on the probe, in the space of ten seconds, hay becomes baking biscuits. That scent change is the end of drying — the water is out and browning can begin. Green coffee colors vary too much to trust your eyes here; experienced roasters call this moment by nose.",
},
{
id: "sn-toast",
title: "The bakery run",
scene: ["snToast", {}],
transcript:
"Browning smells arrive in order: toasted grain, then nuts, then caramel as sugars begin to melt near three-seventy. Notice the texture of the smell too — the humid, steamy edge disappears, leaving dry bakery air. Sweetness is being built right now, and your nose can follow it.",
tts: "Browning smells arrive in order: toasted grain, then nuts, then caramel as sugars begin to melt near three seventy. Notice the texture of the smell too — the humid, steamy edge disappears, leaving dry bakery air. Sweetness is being built right now, and your nose can follow it.",
},
{
id: "sn-sight",
title: "What the trier shows",
scene: ["snSight", {}],
transcript:
"Watch the trier: yellow, then a marbled tan, then brown — with the center crease still clamped shut. Uneven, mottled color at light roast is normal, not a defect. Real expansion and the big chaff release only come when first crack throws the crease open.",
},
{
id: "sn-firstcrack",
title: "First crack: popcorn",
scene: ["snFirstcrack", {}],
transcript:
"First crack sounds like popcorn: loud, low, well-separated pops. Ignore the first stray tick — that's one broken bean, not the batch. Call it when three to five pops land in quick succession. And remember the strange truth: fewer than one bean in ten ever pops out loud.",
},
{
id: "sn-secondcrack",
title: "Second crack: rice krispies",
scene: ["snSecondcrack", {}],
transcript:
"Second crack is a different instrument: a fast, shallow crackle like rice krispies in milk, higher-pitched and quieter per snap but five times as frequent. The water is long gone — this is a brittle, carbonizing structure fracturing under gas pressure, and oils are being pushed to the surface.",
},
{
id: "sn-arcs",
title: "The four flavor arcs",
scene: ["snArcs", {}],
transcript:
"Four arcs tell the flavor story. Acidity is only ever destroyed — the longer you roast, the less survives. Sweetness is a hill that peaks shortly after first crack. Body builds, then hollows out again in dark territory — dark roasts are thinner, not bolder. And bitterness waits, then climbs fast.",
},
{
id: "sn-droplevels",
title: "What each drop tastes like",
scene: ["snDroplevels", {}],
transcript:
"Drop at City and the cup is bright, juicy, and delicate. City-plus is the balance point most home roasters chase. Full City turns bittersweet and chocolatey; a touch further suits espresso. Past that, roast character eclipses the origin — and by French the cup is thinner, smoky, and mostly about the roast itself.",
},
{
id: "sn-smoke",
title: "Steam becomes smoke",
scene: ["snSmoke", {}],
transcript:
"The entire roast, told by your nose: steam slowly becomes smoke. A last wave of deep, sweet 'pre-smoke' aroma means real smoke is thirty seconds away. Heavy smoke means stop — the sweetness is already gone. And if tomorrow's grounds smell sharp and acrid, your charge was too hot today.",
},
],
},
{
id: "gone-wrong",
title: "When roasts go wrong",
minutes: 9,
slides: [
{
id: "ww-good",
title: "What good looks like",
scene: ["wwGood", {}],
transcript:
"Before the failure gallery, the target: a rate of rise that falls smoothly from turning point to drop, beans even in color with the inside matching the outside, weight loss on target — and a cup whose sweetness survives as it cools. Every defect that follows breaks one of these.",
},
{
id: "ww-underdev",
title: "Underdeveloped",
scene: ["wwUnderdev", {}],
transcript:
"Snap a bean in half: a dark surface fading to a pale center means the heat never reached the core. The cup is grassy, sharp, and papery, sour and bitter at once with no sweetness between. Don't just roast darker — that burns the outside while the inside stays raw. Deliver energy earlier, or give development fifteen to thirty more seconds.",
},
{
id: "ww-baked",
title: "Baked — the invisible defect",
scene: ["wwBaked", {}],
transcript:
"Stall the roast and it bakes: flat, bready, cardboard, no sweetness. The chemistry kept running at low energy — it spent the bean's sugars without making the sweet, aromatic compounds you were roasting for. Baked coffee looks perfectly normal; the tell is a cup that tastes okay hot and empty once it cools.",
},
{
id: "ww-crashflick",
title: "Crash and flick",
scene: ["wwCrashflick", {}],
transcript:
"At first crack the beans dump steam and the rate of rise wants to dive; cut heat too late and it crashes, then chase it with gas and it flicks back up. The crash tastes baked, the flick tastes roasty — even at a light color. Both are fixed upstream: smaller, earlier moves, about forty-five seconds before the crack, and never panic-feed a crash.",
},
{
id: "ww-scorch",
title: "Scorching, tipping, facing",
scene: ["wwScorch", {}],
transcript:
"Burn specks before the beans even tan, blackened tips, or charred faces late in the roast — three appearances of one mistake: metal or air hotter than the bean could absorb. All of them taste burnt and ashy over a hollow cup. Drop the charge ten degrees, load the drum properly, keep the beans tumbling.",
},
{
id: "ww-fast",
title: "Too fast — the gradient roast",
scene: ["wwFast", {}],
transcript:
"Coffee conducts heat poorly — the inside needs time. Rush the roast and you don't get an average, you get a gradient: char over raw. The tell is a cup that tastes burnt and grassy at the same time, and no drop temperature can fix it. Slow the early roast and stretch the path to first crack.",
},
{
id: "ww-quakers",
title: "Quakers — the beans that can't brown",
scene: ["wwQuakers", {}],
transcript:
"A few beans stay pale while the batch browns — quakers, seeds picked unripe, that never stored the sugars browning feeds on. Same drum, same heat, no color: proof that roast color is chemistry, not temperature. They taste like paper and peanut shells. You can't roast them out — pick them out of the cooling tray.",
},
{
id: "ww-uneven",
title: "The uneven batch",
scene: ["wwUneven", {}],
transcript:
"An uneven batch is a message — read the pattern. A couple of pale outliers are quakers: the green's fault. A continuous spread of shades means the beans lived different roasts: mixed moisture or density, an overloaded drum, or too little airflow. Size the batch to the machine and buy well-sorted green.",
},
{
id: "ww-airflow",
title: "Air is a heat control",
scene: ["wwAirflow", {}],
transcript:
"Around seventy percent of a drum roaster's heat arrives by air, so the fan is a heat control, not an exhaust switch. Starve it and smoke settles back onto the beans — ashy cups and a chaff fire waiting. Overdo it and you strip aromatics and stall the roast. Run it low through drying, medium through browning, high from just before crack to drop.",
},
{
id: "ww-diagnose",
title: "Taste backwards, fix forwards",
scene: ["wwDiagnose", {}],
transcript:
"Let the cup diagnose the roast. Sour and thin asks for more development. Flat cardboard means you stalled — carry momentum through first crack. Smoky but dull points at a crash and flick, or starved airflow. Then the discipline: one change per batch, written down — and before you blame the roast, make sure it isn't just the brew.",
},
],
},
],
},
{
+653
View File
@@ -87,6 +87,89 @@ function flame(x, y, scale = 1) {
);
}
let halfBeanClipSeq = 0;
/** Cross-section "cut bean" — outer shell path + a clipped inner area that can hold cell
* texture, moisture dots, gradient fills, etc. Same outer/inner split as bean(): the
* positional transform lives on the OUTER group; opts.class (if any) animates the INNER group. */
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 shell = el("path", {
d: shapeD,
fill: opts.shellFill ?? C.tan,
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);
}
/** Small semi-circle dial gauge with a needle that sweeps toward the reading on a loop.
* Positional transform stays on the outer group; the needle's own transform is purely the
* rotation animation (no static transform competing on the same element), so no override risk. */
function gauge(x, y, label, valueLabel, opts = {}) {
const r = opts.r ?? 42;
const color = opts.color ?? C.ember;
const children = [
el("path", {
d: `M ${-r} 0 A ${r} ${r} 0 0 1 ${r} 0`,
fill: "none",
stroke: C.line,
"stroke-width": 8,
"stroke-linecap": "round",
}),
el("line", {
x1: 0, y1: 0, x2: 0, y2: -r + 10,
stroke: color, "stroke-width": 4, "stroke-linecap": "round",
class: opts.cls ?? "ac-gauge-needle",
"transform-origin": "0px 0px",
}),
el("circle", { cx: 0, cy: 0, r: 6, fill: color }),
valueLabel ? text(0, -16, valueLabel, 12.5, C.ink, "middle", "700") : null,
label ? text(0, r + 20, label, 11.5, C.ink2, "middle") : null,
].filter(Boolean);
return el("g", { transform: `translate(${x} ${y})` }, ...children);
}
/** Straight-segment curve through points, optionally drawn live via the ac-draw class. */
const arcCurve = (points, color, cls = "") =>
el("path", {
d: points.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px} ${py}`).join(" "),
fill: "none",
stroke: color,
"stroke-width": 2.5,
class: cls,
});
/** Horizontal phase strip, reused from beanJourney's inline segments. `segments` is
* [{x,w,color,label}]; an optional marker dot can ride a CSS translate class across it. */
function stageStrip(segments, opts = {}) {
const y = opts.y ?? 236, h = opts.h ?? 12;
const out = [];
for (const s of segments) {
out.push(el("rect", { x: s.x, y, width: s.w, height: h, rx: 3, fill: s.color, opacity: 0.55 }));
if (s.label) out.push(text(s.x + s.w / 2, y + 28, s.label, 11, C.ink2));
}
if (opts.markerX != null)
out.push(el("circle", { cx: opts.markerX, cy: y + h / 2, r: 6, fill: opts.markerColor ?? C.ember, class: opts.markerCls ?? "" }));
return out;
}
/** Simple line-glyph for the nose / eye / ear senses, ~24px across, centred on (x,y). */
function senseIcon(kind, x, y, color = C.ink2) {
const g = el("g", { transform: `translate(${x} ${y})`, stroke: color, "stroke-width": 2, fill: "none", "stroke-linecap": "round" });
if (kind === "nose")
g.append(el("path", { d: "M-3 -12 C 8 -10, 10 4, 2 10 C -2 13, -8 12, -10 8", }), el("circle", { cx: 1, cy: 10, r: 1.6, fill: color }));
if (kind === "eye")
g.append(el("path", { d: "M-13 0 C -6 -9, 6 -9, 13 0 C 6 9, -6 9, -13 0 Z" }), el("circle", { cx: 0, cy: 0, r: 4, fill: color }));
if (kind === "ear")
g.append(el("path", { d: "M-4 -12 C 8 -12, 12 -2, 8 6 C 5 11, -2 11, -3 5 C -4 0, 3 1, 2 -4" }));
return g;
}
const SIL_SCALE = 2.6;
const SIL_X = 280 - 32 * SIL_SCALE; // silhouette 64-box centred horizontally
const SIL_Y = 34;
@@ -546,6 +629,576 @@ export const SCENES = {
}
return out;
},
// ── Inside the bean ──
ibCells() {
const bx = 120, by = 140;
const cx = 400, cy = 140, cr = 90;
const hexPositions = [[0, -40], [38, -20], [38, 20], [0, 40], [-38, 20], [-38, -20], [0, 0]];
const hexPath = (x, y, r) => {
const pts = [...Array(6)].map((_, i) => {
const a = Math.PI / 6 + (i * Math.PI) / 3;
return `${x + r * Math.cos(a)} ${y + r * Math.sin(a)}`;
});
return `M${pts.join(" L")} Z`;
};
const cells = hexPositions.map(([dx, dy], i) => {
const hx = cx + dx * 0.9, hy = cy + dy * 0.9;
return el("g", {},
el("path", { d: hexPath(hx, hy, 21), fill: "none", stroke: C.ink2, "stroke-width": 1.3 }),
el("circle", { cx: hx - 7, cy: hy - 4, r: 3, fill: C.water, class: `ac-sparkle ac-delay-${i % 3}` }),
el("circle", { cx: hx + 6, cy: hy - 4, r: 3, fill: C.yellow, class: `ac-sparkle ac-delay-${(i + 1) % 3}` }),
el("circle", { cx: hx, cy: hy + 7, r: 3, fill: C.tan, class: `ac-sparkle ac-delay-${(i + 2) % 3}` }),
);
});
return [
bean(bx, by, 1.85, C.green),
el("circle", { cx, cy, r: cr, fill: C.paper, stroke: C.line, "stroke-width": 2 }),
...cells,
el("line", { x1: bx + 50, y1: by - 28, x2: cx - cr + 8, y2: cy - cr + 18, stroke: C.line, "stroke-width": 1.5, "stroke-dasharray": "3 3" }),
el("line", { x1: bx + 50, y1: by + 28, x2: cx - cr + 8, y2: cy + cr - 18, stroke: C.line, "stroke-width": 1.5, "stroke-dasharray": "3 3" }),
text(cx, cy - cr - 10, "one cell, magnified", 12, C.ink2, "middle"),
caption("one bean ≈ a few million sealed cells — oil, sugar, and water inside each"),
];
},
ibSeal() {
const cx = 280, cy = 140, half = 64;
const pores = [
[cx, cy - half, 0, 28],
[cx, cy + half, 0, -28],
[cx - half, cy, 28, 0],
[cx + half, cy, -28, 0],
];
return [
el("rect", { x: cx - half, y: cy - half, width: half * 2, height: half * 2, rx: 16, fill: "rgba(0,0,0,0.04)", stroke: C.dark, "stroke-width": 4 }),
...pores.map(([px, py, dx, dy], i) =>
el("g", {},
el("rect", { x: px - 9, y: py - 9, width: 18, height: 18, fill: C.paper }),
el("circle", { cx: px - dx, cy: py - dy, r: 9, fill: C.yellow, class: `ac-plug ac-delay-${i}`, style: `--dx:${dx}px;--dy:${dy}px` }),
),
),
...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([sx, sy], i) =>
el("path", {
d: `M${cx + sx * (half + 12)} ${cy + sy * (half + 12)} l ${sx * 10} ${sy * 10}`,
stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}`,
}),
),
text(cx, cy - half - 18, "cell wall", 12, C.ink2, "middle"),
text(cx, cy - 4, "pressure", 12.5, C.ember, "middle", "700"),
text(cx, cy + 14, "building", 12.5, C.ember, "middle", "700"),
caption("early heat drives oil into the pores — each cell seals into a pressure vessel"),
];
},
ibGlass() {
const lx = 140, rx = 420, cy = 150;
return [
bean(lx, cy, 1.6, C.tan),
el("path", { d: `M${lx - 14} ${cy - 24} l 8 14 l -8 10 l 8 12`, stroke: C.paper, "stroke-width": 1.8, fill: "none" }),
text(lx, cy + 92, "glassy", 13, C.ink, "middle", "700"),
text(lx, cy + 108, "hard, locked", 11.5, C.ink2, "middle"),
bean(rx, cy, 1.6, C.yellow, { class: "ac-wobble" }),
text(rx, cy + 92, "rubbery", 13, C.ink, "middle", "700"),
text(rx, cy + 108, "soft, stretchy", 11.5, C.ink2, "middle"),
el("path", { d: `M${lx + 46} ${cy - 50} Q 280 ${cy - 92} ${rx - 46} ${cy - 50}`, fill: "none", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
text(280, cy - 98, "heat", 12.5, C.ember, "middle", "700"),
el("path", { d: `M${rx - 46} ${cy + 50} Q 280 ${cy + 92} ${lx + 46} ${cy + 50}`, fill: "none", stroke: C.water, "stroke-width": 2.5, class: "ac-draw" }),
text(280, cy + 100, "cool + dry", 12.5, C.water, "middle", "700"),
caption("warm + wet = rubbery and inflatable · cool + dry = glassy and locked"),
];
},
ibFront() {
const x = 190, y = 160, scale = 1.55;
return [
halfBean(x, y, scale, {
shellFill: C.tan,
children: [el("circle", { cx: 0, cy: 0, r: 30, fill: C.water, class: "ac-shrink" })],
}),
steamWisps(x, 74, 3),
text(x, 50, "water leaves the surface", 12, C.water, "middle"),
el("line", { x1: x + 50, y1: y - 30, x2: 356, y2: 70, stroke: C.line, "stroke-width": 1.5 }),
text(364, 66, "dry shell — hot, reacting", 12, C.tan, "start", "700"),
el("line", { x1: x + 26, y1: y + 12, x2: 356, y2: 180, stroke: C.line, "stroke-width": 1.5 }),
text(364, 176, "wet core — pinned", 12, C.water, "start", "700"),
text(364, 194, "near boiling", 12, C.ink2, "start"),
caption("water leaves outside-in — a wet core retreats behind a hot, dry shell"),
];
},
ibPressure() {
const bars = [["car tire", 2, C.ink2], ["espresso", 9, C.water], ["first crack", 15, C.ember]];
const baseY = 232, maxH = 130, maxV = 15;
return [
gauge(140, 150, null, "≈15 bar", { r: 46 }),
text(140, 216, "pressure follows", 11, C.ink2, "middle"),
text(140, 230, "temperature", 11, C.ink2, "middle"),
...bars.map(([label, val, color], i) => {
const x = 300 + i * 76;
const h = (val / maxV) * maxH;
return el("g", {},
el("rect", { x: x - 20, y: baseY - h, width: 40, height: h, rx: 6, fill: color, opacity: i === 2 ? 1 : 0.55 }),
text(x, baseY + 18, label, 11, C.ink2, "middle"),
text(x, baseY - h - 8, `${val} bar`, 11.5, C.ink, "middle", "700"),
);
}),
caption("while water remains, physics pins the pressure to the temperature"),
];
},
ibCrack() {
const x = 280, y = 150, scale = 1.7;
return [
halfBean(x, y, scale, {
shellFill: C.brown, strokeWidth: 5, stroke: C.dark,
children: [
el("circle", { cx: 0, cy: 0, r: 26, fill: C.tan }),
...[0, 1, 2, 3].map((i) => {
const a = i * (Math.PI / 2) + Math.PI / 4;
const x1 = Math.cos(a) * 30, y1 = Math.sin(a) * 30, x2 = Math.cos(a) * 40, y2 = Math.sin(a) * 40;
return el("path", { d: `M${x1} ${y1} L${x2} ${y2}`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` });
}),
...[[-30, -46], [30, -46], [-36, 40], [36, 40]].map(([sx, sy]) =>
el("path", { d: `M${sx - 4} ${sy} l 8 0`, stroke: C.paper, "stroke-width": 1.6 }),
),
],
}),
el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
el("path", { d: "M-2 -50 L 6 -20 L -6 6 L 6 30 L -2 52", fill: "none", stroke: C.paper, "stroke-width": 2.6, class: "ac-crackline" }),
),
text(x, y - scale * 58 - 14, "stiff, dried shell", 12, C.ink2, "middle"),
text(x, 250, "fewer than 1 in 10 beans pops audibly", 12, C.ember, "middle", "700"),
caption("a rubbery, pressurized core strains against a stiff shell — until it fails"),
];
},
ibFoam() {
const y = 150, leftX = 160, rightX = 400;
const pores = (n, seed) => [...Array(n)].map((_, i) => {
const ang = (i / n) * Math.PI * 2 + seed;
const rad = 14 + (i % 3) * 8;
return el("circle", { cx: Math.cos(ang) * rad, cy: Math.sin(ang) * rad, r: 3 + (i % 2), fill: C.paper, opacity: 0.85 });
});
return [
halfBean(leftX, y, 1.3, { shellFill: C.green, children: pores(5, 0) }),
text(leftX, y + 80, "green", 12.5, C.ink, "middle", "700"),
text(leftX, y + 98, "14% air · density 1.1", 11, C.ink2, "middle"),
halfBean(rightX, y, 1.3, { shellFill: C.tan, class: "ac-grow-foam", children: pores(11, 1) }),
text(rightX, y + 80, "roasted", 12.5, C.ink, "middle", "700"),
text(rightX, y + 98, "~46% air · density 0.65", 11, C.ink2, "middle"),
el("path", { d: `M${leftX + 70} ${y} L ${rightX - 70} ${y}`, stroke: C.ember, "stroke-width": 2, "stroke-dasharray": "4 4" }),
text(280, y - 6, "≈1.5×", 12.5, C.ember, "middle", "700"),
text(280, 244, "expansion is steady — not a sudden pop", 12, C.ink2, "middle"),
caption("half the roasted bean is air — and it swelled steadily, not in one bang"),
];
},
ibMortar() {
const gx = 150, gy = 56, cw = 100, ch = 58;
const bricks = [];
for (let r = 0; r < 2; r++)
for (let c = 0; c < 3; c++)
bricks.push(el("rect", { x: gx + c * cw, y: gy + r * ch, width: cw - 10, height: ch - 10, rx: 6, fill: C.tan, class: `ac-mortar-fade ac-delay-${(r + c) % 3}` }));
const rebarXs = [gx + 8, gx + 8 + cw, gx + 8 + cw * 2];
const rebar = rebarXs.map((rx, i) =>
el("g", {},
el("rect", { x: rx, y: gy - 16, width: 9, height: ch * 2 + 30, rx: 4, fill: C.dark }),
i === 1 ? el("path", { d: `M${rx + 4} ${gy + ch * 2 - 2} l 6 10 l -6 8 l 6 10`, stroke: C.paper, "stroke-width": 2, fill: "none", class: "ac-rebar-snap" }) : null,
),
);
return [
text(280, 34, "cell wall, close up", 12, C.ink2, "middle"),
...bricks, ...rebar,
text(280, 210, "mortar: soft sugars & gums", 12, C.tan, "middle", "700"),
text(280, 226, "— up to 60% dissolves away", 11, C.ink2, "middle"),
text(280, 244, "rebar: cellulose barely reacts", 12, C.dark, "middle", "700"),
text(280, 260, "chars & snaps only very dark — 2nd crack", 10.5, C.ink2, "middle"),
caption("the wall's soft mortar dissolves so the bean stretches — cellulose rebar holds"),
];
},
ibCo2() {
const beanX = 130, groundX = 380, y = 130;
return [
bean(beanX, y, 1.7, C.brown, { crack: true }),
...[0, 1, 2].map((i) => el("circle", { cx: beanX - 14 + i * 14, cy: y - 40 - i * 6, r: 3, fill: C.tan, class: `ac-rise ac-delay-${i}` })),
text(beanX, y + 70, "whole bean", 12.5, C.ink, "middle", "700"),
text(beanX, y + 88, "~a month to degas", 10.5, C.ink2, "middle"),
...[...Array(9)].map((_, i) => el("circle", { cx: groundX - 30 + (i % 3) * 30, cy: y + 10 + Math.floor(i / 3) * 18, r: 8, fill: C.brown })),
...[0, 1, 2, 3, 4].map((i) => el("circle", { cx: groundX - 24 + i * 12, cy: y - 30 - i * 4, r: 3.5, fill: C.tan, class: `ac-burst ac-delay-${i % 3}` })),
text(groundX, y + 70, "ground", 12.5, C.ink, "middle", "700"),
text(groundX, y + 88, "grinding: half escapes fast", 10.5, C.ink2, "middle"),
el("path", { d: "M255 220 h 50 l -6 26 h -38 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("rect", { x: 259, y: 220, width: 42, height: 6, fill: C.tan }),
text(280, 258, "bloom & crema — CO₂ escaping into the cup", 11, C.ink2, "middle"),
caption("a dark roast holds ~8 liters of CO₂ per kilo — bloom and crema are its escape"),
];
},
ibColor() {
const leftX = 150, rightX = 410, beanY = 96;
const bars = (cx, vol, den) => {
const hMax = 40, y0 = 196;
const volH = (vol / 1.8) * hMax, denH = (den / 0.9) * hMax;
return [
el("rect", { x: cx - 34, y: y0 + hMax - volH, width: 22, height: volH, rx: 3, fill: C.ember }),
text(cx - 23, y0 + hMax + 14, `${vol}×`, 10.5, C.ink2, "middle"),
el("rect", { x: cx + 12, y: y0 + hMax - denH, width: 22, height: denH, rx: 3, fill: C.water }),
text(cx + 23, y0 + hMax + 14, `${den}`, 10.5, C.ink2, "middle"),
];
};
return [
bean(leftX, beanY, 1.4, C.brown),
bean(rightX, beanY, 1.4, C.brown),
text(280, beanY + 6, "=", 26, C.pass, "middle", "700"),
text(leftX, beanY + 62, "fast & hot", 12.5, C.ink, "middle", "700"),
text(rightX, beanY + 62, "slow & low", 12.5, C.ink, "middle", "700"),
text(leftX - 23, 190, "vol", 10, C.ink2, "middle"),
text(leftX + 23, 190, "den", 10, C.ink2, "middle"),
text(rightX - 23, 190, "vol", 10, C.ink2, "middle"),
text(rightX + 23, 190, "den", 10, C.ink2, "middle"),
...bars(leftX, 1.7, 0.62),
...bars(rightX, 1.4, 0.75),
text(280, 220, "≠", 22, C.fail, "middle", "700"),
caption("equal color can hide different chemistry — that's why we log the whole curve"),
];
},
// ── Roasting by the senses ──
snInstruments() {
return [
senseIcon("nose", 160, 70, C.ink2),
senseIcon("eye", 280, 70, C.ink2),
senseIcon("ear", 400, 70, C.ink2),
text(160, 100, "smell", 11, C.ink2, "middle"),
text(280, 100, "sight", 11, C.ink2, "middle"),
text(400, 100, "hearing", 11, C.ink2, "middle"),
el("rect", { x: 271, y: 122, width: 18, height: 66, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("circle", { cx: 280, cy: 196, r: 13, fill: C.ember }),
el("rect", { x: 276, y: 130, width: 8, height: 56, rx: 4, fill: C.ember, class: "ac-fill-up" }),
el("rect", { x: 330, y: 150, width: 150, height: 30, rx: 8, fill: C.emberSoft, stroke: C.ember, "stroke-width": 1.4 }),
text(405, 170, "±50°F between machines", 11, C.ember, "middle", "700"),
...stageStrip([
{ x: 60, w: 140, color: C.yellow, label: "drying" },
{ x: 210, w: 140, color: C.tan, label: "Maillard" },
{ x: 360, w: 140, color: C.brown, label: "development" },
]),
caption("probes disagree machine to machine — your senses read the beans directly"),
];
},
snGrass() {
return [
bean(170, 150, 1.9, C.green, { class: "ac-grass-fade" }),
steamWisps(170, 92, 3),
text(170, 60, "steam — not smoke", 11.5, C.ink2, "middle"),
text(360, 110, "cut grass", 13, C.pass, "start", "700"),
text(360, 150, "wet hay,", 13, C.tan, "start", "700"),
text(360, 170, "damp grain", 13, C.tan, "start", "700"),
caption("early aromas ride on visible steam: cut grass, then wet hay and damp grain"),
];
},
snHinge() {
return [
senseIcon("nose", 280, 66, C.ink2),
bean(280, 150, 1.7, C.yellow, { class: "ac-breathe" }),
el("g", { class: "ac-cross-a" }, text(280, 222, "drying hay", 14, C.ink2, "middle", "700")),
el("g", { class: "ac-cross-b" }, text(280, 222, "baking biscuits", 14, C.ember, "middle", "700")),
text(150, 150, "≈330 °F", 12.5, C.ink, "middle", "700"),
...stageStrip(
[
{ x: 90, w: 190, color: C.yellow, label: "drying" },
{ x: 280, w: 190, color: C.tan, label: "Maillard" },
],
{ markerX: 280, markerColor: C.ember, markerCls: "ac-marker-cross" },
),
caption("the ten-second moment barn smell turns bakery — roasters mark it by nose"),
];
},
snToast() {
const items = [["toast", 210, 0], ["nuts", 170, 1], ["caramel", 130, 2]];
return [
bean(160, 190, 1.7, C.tan, { class: "ac-maillard-shift" }),
steamWisps(160, 128, 2),
text(160, 92, "the smell dries out", 11.5, C.ink2, "middle"),
...items.map(([label, y, i]) =>
el("g", { class: `ac-rise ac-delay-${i}` }, text(360, y, label, 14, i === 2 ? C.ember : C.tan, "middle", "700")),
),
el("line", { x1: 300, y1: 230, x2: 420, y2: 230, stroke: C.line, "stroke-width": 1.5 }),
text(360, 246, "≈370 °F — sugars melt", 11.5, C.ink2, "middle"),
caption("toast, then nuts, then caramel — and the smell itself turns from damp to dry"),
];
},
snSight() {
const stages = [
["green", C.green, false],
["pale yellow", C.yellow, false],
["marbled tan", "#c9a468", false],
["brown", C.brown, false],
["expanded", "#9c6a3a", true],
];
const xs = [90, 190, 290, 390, 490];
const out = [];
stages.forEach(([label, color, cracked], i) => {
out.push(bean(xs[i], 130, 1.05, color, cracked ? { crack: true } : {}));
out.push(text(xs[i], 190, label, 10.5, C.ink2, "middle"));
});
out.push(...[0, 1].map((j) => el("path", { d: `M${xs[4] + 22 + j * 6} ${118 - j * 8} q 8 3 12 -3`, stroke: C.tan, "stroke-width": 2, fill: "none", class: `ac-drift ac-delay-${j}` })));
out.push(text(290, 208, "mottled at light roast = normal", 11.5, C.ember, "middle", "700"));
out.push(caption("yellow, marbled, brown; the crease stays shut until first crack opens it"));
return out;
},
snFirstcrack() {
const baseY = 170;
const spikes = [80, 150, 230, 340, 420];
return [
senseIcon("ear", 280, 60, C.ink2),
el("line", { x1: 50, y1: baseY, x2: 510, y2: baseY, stroke: C.line, "stroke-width": 1.5 }),
...spikes.map((x, i) =>
el("line", { x1: x, y1: baseY, x2: x, y2: baseY - 46 - (i % 2) * 10, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i % 3}` }),
),
text(280, baseY + 24, "~800 Hz, loud, discrete — popcorn", 11.5, C.ink2, "middle"),
el("rect", { x: 55, y: 200, width: 450, height: 30, rx: 8, fill: C.paper, stroke: C.line }),
text(280, 219, "call it on 35 rapid pops, not the first tick", 10, C.ink, "middle", "700"),
text(280, 248, "fewer than 1 in 10 beans pops audibly", 11.5, C.ember, "middle", "700"),
caption("sharp, low, popcorn pops — mark first crack at 35 in quick succession"),
];
},
snSecondcrack() {
const baseY = 170;
const spikes = [...Array(9)].map((_, i) => 60 + i * 32);
return [
bean(430, 150, 1.4, C.dark, { crack: true }),
el("circle", { cx: 424, cy: 140, r: 4, fill: "#f0d9a0", opacity: 0.85 }),
text(430, 210, "dry, brittle, oily", 11, C.ink2, "middle"),
el("line", { x1: 40, y1: baseY, x2: 340, y2: baseY, stroke: C.line, "stroke-width": 1.5 }),
...spikes.map((x, i) =>
el("line", { x1: x, y1: baseY, x2: x, y2: baseY - 18 - (i % 2) * 6, stroke: C.ember, "stroke-width": 2, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i % 3}` }),
),
text(220, baseY + 24, "~19× higher, 5× faster, quieter", 10, C.ink2, "middle"),
text(220, baseY + 40, "rice krispies · electrical sparking", 10.5, C.ink2, "middle"),
caption("a fast, shallow crackle from a dry, brittle bean — gas fracture, not steam pop"),
];
},
snArcs() {
const X0 = 60, X1 = 500, Y0 = 70, YB = 210;
return [
el("path", { d: `M${X0} ${YB} H ${X1} M${X0} ${YB} V ${Y0}`, stroke: C.line, "stroke-width": 1.5, fill: "none" }),
arcCurve([[X0, Y0 + 10], [180, 90], [340, 140], [X1, 170]], C.water, "ac-draw"),
text(70, 82, "acidity", 11, C.water, "start", "700"),
arcCurve([[X0, YB - 20], [220, Y0 + 5], [320, Y0 + 15], [X1, YB - 40]], C.tan, "ac-draw"),
text(210, Y0 - 6, "sweetness", 11, C.tan, "middle", "700"),
arcCurve([[X0, YB - 60], [220, YB - 100], [360, YB - 90], [X1, YB - 130]], C.brown, "ac-draw"),
text(360, YB - 96, "body", 11, C.brown, "start", "700"),
arcCurve([[X0, YB - 4], [300, YB - 10], [400, YB - 40], [X1, Y0 + 4]], C.fail, "ac-draw"),
text(430, YB - 34, "bitterness", 11, C.fail, "start", "700"),
...stageStrip([
{ x: X0, w: 146, color: C.yellow, label: "drying" },
{ x: X0 + 146, w: 146, color: C.tan, label: "Maillard" },
{ x: X0 + 292, w: 148, color: C.brown, label: "development" },
], { markerX: 300, markerColor: C.ember, markerCls: "ac-slide-x" }),
caption("acidity only falls · sweetness peaks · body rounds over · bitterness arrives"),
];
},
snDroplevels() {
const stages = [
["City", "#c9a468", "bright·juicy"],
["City+", "#b98d54", "balance point"],
["Full City", "#96652f", "bittersweet"],
["FC+", "#7d4f26", "espresso"],
["Vienna", "#5f3a1c", "eclipses origin"],
["French", "#3c2413", "thin & smoky"],
];
const xs = [72, 158, 244, 330, 416, 502];
const out = [];
stages.forEach(([label, color, tag], i) => {
out.push(bean(xs[i], 120, 0.85, color));
out.push(text(xs[i], 168, label, 10.5, C.ink, "middle", "700"));
out.push(text(xs[i], 182, tag, 9.5, C.ink2, "middle"));
});
out.push(caption("the same coffee, six different cups — the drop decides which one you drink"));
return out;
},
snSmoke() {
const stages = [
[110, 0.4, C.ink2, "steam"],
[280, 0.6, "#a89e8f", "thin smoke"],
[450, 0.9, "#3a332c", "heavy smoke"],
];
const out = [];
stages.forEach(([x, op, color, label], i) => {
out.push(...[0, 1, 2].map((j) =>
el("path", {
d: `M${x + (j - 1) * 16} 200 c -6 -14, 6 -22, 0 -36 c -5 -12, 5 -20, 0 -34`,
fill: "none", stroke: color, "stroke-width": 3 + i * 1.5, "stroke-linecap": "round",
class: `ac-steam ac-delay-${j}`, opacity: op,
}),
));
out.push(text(x, 224, label, 12, color === C.ink2 ? C.ink2 : color, "middle", "700"));
});
out.push(text(80, 250, "pre-smoke = ~30s warn", 10, C.ink2, "start"));
out.push(text(280, 250, "heavy smoke = stop", 10.5, C.fail, "middle", "700"));
out.push(text(500, 250, "acrid next day = too hot", 10, C.ink2, "end"));
out.push(caption("the whole aroma timeline in four words: steam slowly becomes smoke"));
return out;
},
// ── When roasts go wrong ──
wwGood() {
return [
el("path", { d: "M60 220 H 500 M60 220 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
text(52, 70, "RoR", 11, C.ink2, "end"),
arcCurve([[64, 84], [160, 118], [280, 150], [400, 176], [494, 196]], C.pass, "ac-draw"),
...["even color", "interior matches exterior", "weight loss on target", "sweetness survives cooling"].map((item, i) =>
el("g", {},
el("circle", { cx: 340, cy: 60 + i * 28, r: 7, fill: C.pass }),
el("path", { d: `M336 ${60 + i * 28} l 3 3 l 5 -6`, stroke: C.paper, "stroke-width": 1.6, fill: "none" }),
text(354, 65 + i * 28, item, 11, C.ink2, "start"),
),
),
caption("a smoothly falling RoR, even color, matched core & surface, a cup that lasts"),
];
},
wwUnderdev() {
return [
halfBean(160, 150, 1.7, {
shellFill: C.brown, strokeWidth: 3,
children: [el("circle", { cx: 0, cy: 6, r: 26, fill: "#e8d9b0" })],
}),
text(160, 232, "dark outside, pale inside", 11.5, C.ink2, "middle"),
el("path", { d: "M340 220 H 500 M340 220 V 100", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
arcCurve([[344, 130], [400, 170], [440, 196]], C.fail, "ac-draw"),
el("line", { x1: 440, y1: 100, x2: 440, y2: 220, stroke: C.ink2, "stroke-width": 1.4, "stroke-dasharray": "3 3" }),
text(440, 92, "dropped early", 10.5, C.ink2, "middle"),
text(420, 240, "grassy · sour · papery", 12, C.fail, "middle", "700"),
caption("brown outside, raw inside — heat never reached the core"),
];
},
wwBaked() {
return [
bean(150, 140, 1.8, C.brown),
text(150, 210, "looks fine!", 12.5, C.pass, "middle", "700"),
el("path", { d: "M330 220 H 500 M330 220 V 90", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
arcCurve([[334, 110], [380, 140], [420, 148], [470, 150], [496, 152]], C.fail, "ac-draw"),
el("path", { d: "M334 110 L400 140 L496 175", fill: "none", stroke: C.pass, "stroke-width": 2.2, "stroke-dasharray": "6 5" }),
text(415, 100, "stalled — flat", 11, C.fail, "middle", "700"),
text(280, 240, "flavor fades as it cools", 12.5, C.ink2, "middle"),
caption("a stalled roast spends the sugars without buying the flavor — and looks normal"),
];
},
wwCrashflick() {
return [
el("path", { d: "M60 230 H 500 M60 230 V 70", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
text(52, 84, "RoR", 11, C.ink2, "end"),
arcCurve([[64, 96], [180, 140], [260, 158], [300, 210], [340, 168], [400, 176], [494, 200]], C.fail, "ac-draw"),
el("circle", { cx: 300, cy: 210, r: 5.5, fill: C.fail, class: "ac-pulse" }),
text(300, 228, "crash", 11.5, C.fail, "middle", "700"),
text(280, 248, "steam dump + heat cut too late", 10.5, C.ink2, "middle"),
el("circle", { cx: 340, cy: 168, r: 5.5, fill: C.fail, class: "ac-pulse ac-delay-1" }),
text(360, 150, "flick", 11.5, C.fail, "middle", "700"),
text(420, 96, "gas added in a panic", 10.5, C.ink2, "middle"),
text(190, 96, "fix: smaller cuts,", 10.5, C.ember, "middle", "700"),
text(190, 110, "~45s BEFORE crack", 10.5, C.ember, "middle", "700"),
caption("the V-shape around first crack — crash bakes the cup, flick sears it roasty"),
];
},
wwScorch() {
const items = [
["scorch", 130, C.tan, "early, drum too hot"],
["tipping", 280, "#6b4423", "too much heat, soft"],
["facing", 430, C.dark, "late, held on metal"],
];
const out = [];
items.forEach(([label, x, color, sub]) => {
out.push(bean(x, 130, 1.3, color));
if (label === "scorch")
out.push(...[0, 1, 2].map((j) => el("circle", { cx: x - 10 + j * 10, cy: 118 + (j % 2) * 6, r: 2.2, fill: C.dark })));
if (label === "tipping")
out.push(el("path", { d: `M${x} 96 q 6 -10 0 -18 q -6 8 0 18`, fill: C.dark }));
if (label === "facing")
out.push(el("ellipse", { cx: x, cy: 130, rx: 22, ry: 30, fill: C.dark, opacity: 0.75 }));
out.push(text(x, 190, label, 12.5, C.ink, "middle", "700"));
out.push(text(x, 206, sub, 10, C.ink2, "middle"));
});
out.push(text(280, 240, "burnt · ashy · smoky over a hollow cup", 12, C.fail, "middle", "700"));
out.push(caption("three marks, one story: the surface got hotter than the bean could carry"));
return out;
},
wwFast() {
return [
halfBean(160, 150, 1.8, {
shellFill: C.dark, strokeWidth: 3,
children: [el("circle", { cx: 4, cy: 10, r: 24, fill: "#dccfa8" })],
}),
text(160, 236, "char over raw", 11.5, C.ink2, "middle"),
el("path", { d: "M330 220 H 500 M330 220 V 70", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
arcCurve([[334, 210], [360, 90], [390, 130], [430, 170], [470, 196], [496, 206]], C.fail, "ac-draw"),
text(360, 78, "towering, early", 10.5, C.ink2, "middle"),
text(460, 240, "burnt AND grassy", 12, C.fail, "middle", "700"),
caption("the one defect no drop change fixes — outside charred while inside dried"),
];
},
wwQuakers() {
const xs = [90, 150, 210, 270, 330, 390, 450];
const paleIdx = new Set([2, 5]);
const out = [];
xs.forEach((x, i) => {
const pale = paleIdx.has(i);
out.push(bean(x, 150, 0.95, pale ? "#d8c58c" : C.brown, {}));
if (pale) out.push(el("circle", { cx: x, cy: 150, r: 30, fill: "none", stroke: C.ember, "stroke-width": 2, class: "ac-ring" }));
});
out.push(
el("g", { class: "ac-tamp" },
el("path", { d: "M210 70 L 200 118 M210 70 L 220 118", stroke: C.ink2, "stroke-width": 3, "stroke-linecap": "round", fill: "none" }),
),
);
out.push(text(280, 200, "unripe: no sugar = no browning fuel", 11.5, C.ink2, "middle"));
out.push(caption("no sugars, no Maillard — color is chemistry, and these beans came without fuel"));
return out;
},
wwUneven() {
const colors = ["#cdbd8f", "#c2a468", "#b98d54", "#a5713a", "#8a5a30", "#6b4423", "#54371f"];
const xs = colors.map((_, i) => 80 + i * 62);
const out = colors.map((c, i) => bean(xs[i], 120, 0.9, c));
out.push(
text(150, 190, "few pale outliers", 11.5, C.ink2, "middle", "700"),
text(150, 206, "→ quakers (green's fault)", 10.5, C.ink2, "middle"),
text(430, 190, "continuous spread", 11.5, C.ink2, "middle", "700"),
text(430, 206, "→ moisture, drum, airflow", 10.5, C.ink2, "middle"),
caption("read the pattern: outliers are the green's fault, a spread is the roast's"),
);
return out;
},
wwAirflow() {
return [
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)),
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"),
text(400, 150, "too much →", 11.5, C.fail, "start", "700"),
text(400, 166, "strips aromatics,", 10.5, C.ink2, "start"),
text(400, 180, "stalls the roast", 10.5, C.ink2, "start"),
...stageStrip([
{ x: 60, w: 150, color: C.yellow, label: "low: drying" },
{ x: 214, w: 150, color: C.tan, label: "medium: browning" },
{ x: 368, w: 132, color: C.ember, label: "high: crack→drop" },
]),
caption("most drum heat rides the air — low drying, medium browning, high through crack"),
];
},
wwDiagnose() {
const rows = [
["sour & thin", "→ more development", 0],
["flat & cardboard", "→ keep momentum, don't stall", 1],
["smoky & dull", "→ fix crash-flick, add air", 2],
];
const out = [];
rows.forEach(([taste, fix, i], idx) => {
const y = 76 + idx * 46;
out.push(
el("rect", { x: 60, y: y - 22, width: 440, height: 38, rx: 8, fill: C.emberSoft, class: `ac-row-glow ac-delay-${i}` }),
text(80, y, taste, 12.5, C.ink, "start", "700"),
text(280, y, fix, 12, C.ember, "start"),
);
});
out.push(text(280, 236, "one change per batch — and check your brew first", 12.5, C.ink, "middle", "700"));
out.push(caption("the cup names the fix — change one thing, rule out the brew before the roast"));
return out;
},
};
+2 -2
View File
@@ -112,7 +112,7 @@ async function loadPlans() {
...plans.map((plan) => {
const tr = document.createElement("tr");
const title = document.createElement("td");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
title.textContent = plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan";
const updated = document.createElement("td");
updated.textContent = fmtDate(plan.updated_at);
const actions = document.createElement("td");
@@ -150,7 +150,7 @@ async function loadPlans() {
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.download = `${(plan.name || plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
+1 -1
View File
@@ -101,7 +101,7 @@ async function loadPlanOptions() {
...plans.map((p) => {
const option = document.createElement("option");
option.value = p.id;
option.textContent = p.plan?.fields?.["0.1"] || "Untitled plan";
option.textContent = p.name || p.plan?.fields?.["0.1"] || "Untitled plan";
return option;
}),
);
+6 -3
View File
@@ -35,11 +35,12 @@ function renderLots() {
const lotCell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = lot.origin;
strong.textContent = lot.name || lot.origin;
const sub = document.createElement("div");
sub.className = "muted";
sub.style.fontSize = "11.5px";
sub.textContent = [lot.variety, lot.producer].filter(Boolean).join(" · ") || "—";
const subParts = lot.name ? [lot.origin, lot.variety, lot.producer] : [lot.variety, lot.producer];
sub.textContent = subParts.filter(Boolean).join(" · ") || "—";
lotCell.append(strong, sub);
const processCell = document.createElement("td");
@@ -107,7 +108,7 @@ function renderLots() {
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", (event) =>
guarded(event.currentTarget, async () => {
if (!confirm(`Delete the ${lot.origin} lot? This cannot be undone.`))
if (!confirm(`Delete the ${lot.name || lot.origin} lot? This cannot be undone.`))
return;
try {
await api(`/api/inventory/${lot.id}`, { method: "DELETE" });
@@ -178,6 +179,7 @@ function startEdit(lot) {
editingId = lot.id;
const form = document.getElementById("lot-form");
form.id.value = lot.id;
form.name.value = lot.name || "";
form.origin.value = lot.origin;
form.variety.value = lot.variety;
form.process.value = lot.process;
@@ -221,6 +223,7 @@ document.getElementById("lot-form").addEventListener("submit", (event) => {
guarded(submitBtn, async () => {
try {
const payload = {
name: data.name,
origin: data.origin,
variety: data.variety,
process: data.process,
+6 -5
View File
@@ -46,7 +46,7 @@ export function initLotPicker({
.map((l) =>
Object.assign(document.createElement("option"), {
value: l.id,
textContent: `${l.origin}${l.variety ? `${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`,
textContent: `${l.name || l.origin}${l.variety ? `${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`,
}),
),
);
@@ -92,7 +92,7 @@ export function initLotPicker({
consumeBtn.classList.remove("hidden");
const weightG = Number(state.plan.fields["0.4"]);
const lot = findLot(lotId);
const lotLabel = lot ? lot.origin : state.plan.inventory.lotLabel || "this lot";
const lotLabel = lot ? (lot.name || lot.origin) : state.plan.inventory.lotLabel || "this lot";
if (Number.isFinite(weightG) && weightG > 0) {
consumeLabel.textContent = `Charging will draw ${weightG} g from ${lotLabel}.`;
consumeBtn.disabled = false;
@@ -148,15 +148,16 @@ export function initLotPicker({
const lotId = select.value;
state.plan.inventory.lotId = lotId;
const lot = findLot(lotId);
const displayName = lot ? lot.name || lot.origin : "";
state.plan.inventory.lotLabel = lot
? `${lot.origin}${lot.variety ? `${lot.variety}` : ""}`
? `${displayName}${lot.variety ? `${lot.variety}` : ""}`
: "";
if (lot) {
note.textContent = `${Math.round(lot.remainingWeightG)} g remaining in this lot.`;
const nameInput = document.querySelector('[name="0.1"]');
if (nameInput && !nameInput.value) {
nameInput.value = lot.origin;
state.plan.fields["0.1"] = lot.origin;
nameInput.value = displayName;
state.plan.fields["0.1"] = displayName;
}
} else {
note.textContent =
+47 -6
View File
@@ -28,6 +28,9 @@ const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null;
let remotePlanId = null;
let draftSyncedAt = null;
// The custom name of the currently loaded plan (server column, not part of the plan JSONB —
// see roast_plans.name). Header display prefers this over worksheet field 0.1 when set.
let currentPlanName = "";
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
@@ -321,7 +324,7 @@ export function renderFormFromPlan() {
}
updateBlendVisibility(state.plan.fields["2.1"]);
document.getElementById("header-coffee-name").textContent =
state.plan.fields["0.1"] || "New plan";
currentPlanName || state.plan.fields["0.1"] || "New plan";
}
function fmtOut(id, text) {
@@ -389,9 +392,9 @@ function renderLedger() {
fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent =
state.plan.fields["0.1"] || "";
currentPlanName || state.plan.fields["0.1"] || "";
document.getElementById("header-coffee-name").textContent =
state.plan.fields["0.1"] || "New plan";
currentPlanName || state.plan.fields["0.1"] || "New plan";
return ledger;
}
@@ -580,6 +583,7 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
const body = await response.json();
remotePlanId = body.plan.id;
draftSyncedAt = body.plan.updated_at;
currentPlanName = body.plan.name ?? currentPlanName;
if (storageKey)
localStorage.setItem(
storageKey,
@@ -838,6 +842,11 @@ function wireDrawers() {
return { open, closeAll };
}
// The custom `name` column wins over worksheet field 0.1 wherever a plan's title is shown —
// mirrors the server's COALESCE(NULLIF(name,''), plan->'fields'->>'0.1') fallback.
function planDisplayTitle(plan) {
return plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan";
}
async function loadPlans() {
if (!navigator.onLine) return;
const response = await fetch("/api/plans");
@@ -853,12 +862,42 @@ async function loadPlans() {
button.className = "plan-list-item";
button.classList.toggle("active", plan.id === remotePlanId);
const title = document.createElement("strong");
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
title.textContent = planDisplayTitle(plan);
const updated = document.createElement("span");
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
button.append(title, updated);
button.addEventListener("click", () => selectPlan(plan));
const renameBtn = document.createElement("button");
renameBtn.type = "button";
renameBtn.className = "icon-btn plan-list-rename";
renameBtn.style.flexShrink = "0";
renameBtn.style.alignSelf = "center";
renameBtn.setAttribute("aria-label", "Rename plan");
renameBtn.textContent = "✎";
// A sibling of the select button, not nested inside it — stopPropagation alone
// wouldn't be enough to keep a click here from also selecting the plan otherwise.
renameBtn.addEventListener("click", async (event) => {
event.stopPropagation();
const next = prompt("Plan name", plan.name || "");
if (next === null) return;
try {
await api(`/api/plans/${plan.id}`, {
method: "PUT",
body: JSON.stringify({ name: next.trim() }),
});
} catch (error) {
showToast(error.message, "fail");
return;
}
if (plan.id === remotePlanId) {
currentPlanName = next.trim();
renderFormFromPlan();
renderLedger();
}
await loadPlans();
});
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "icon-btn plan-list-delete";
@@ -871,7 +910,7 @@ async function loadPlans() {
deletePlan(plan);
});
item.append(button, deleteBtn);
item.append(button, renameBtn, deleteBtn);
return item;
}),
);
@@ -880,6 +919,7 @@ async function selectPlan(plan) {
if (!(await flushCurrentPlan())) return;
remotePlanId = plan.id;
draftSyncedAt = plan.updated_at;
currentPlanName = plan.name || "";
state.plan = { ...blankPlan(), ...plan.plan };
history.replaceState(
null,
@@ -903,6 +943,7 @@ async function selectPlan(plan) {
function resetToBlankPlan() {
remotePlanId = null;
draftSyncedAt = null;
currentPlanName = "";
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend();
@@ -922,7 +963,7 @@ async function newPlan() {
document.querySelector("[data-close-drawer]")?.click();
}
async function deletePlan(plan) {
const name = plan.plan?.fields?.["0.1"] || "Untitled plan";
const name = planDisplayTitle(plan);
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
+1 -1
View File
@@ -343,7 +343,7 @@ async function openDetail(id, { keepScroll = false } = {}) {
...planList.map((plan) =>
Object.assign(document.createElement("option"), {
value: plan.id,
textContent: plan.plan?.fields?.["0.1"] || "Untitled plan",
textContent: plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan",
}),
),
);
+2 -2
View File
@@ -15,7 +15,7 @@
<body>
<header class="marketing-header">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span>Roast Planner</span>
</div>
<nav>
@@ -72,7 +72,7 @@
</p>
<div class="feature-grid">
<div class="feature-card">
<div class="feature-icon" aria-hidden="true"></div>
<div class="feature-icon" aria-hidden="true"><img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" /></div>
<h3>The Time Ledger</h3>
<p>
Anchor first crack by cultivar, then let refinements, bean
+1 -1
View File
@@ -12,7 +12,7 @@
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<strong>Roast Planner</strong>
</div>
<h1>Log in</h1>
+1 -1
View File
@@ -12,7 +12,7 @@
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<strong>Roast Planner</strong>
</div>
<h1>Set a new password</h1>
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="app-shell">
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
<div class="side-nav-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<span class="brand-name">Roast Planner</span>
</div>
<div id="nav-links"></div>
+1 -1
View File
@@ -12,7 +12,7 @@
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<strong>Roast Planner</strong>
</div>
<h1>Create the administrator account</h1>
+1 -1
View File
@@ -12,7 +12,7 @@
<main class="auth-page">
<section class="panel-card auth-card">
<div class="auth-brand">
<span class="brand-mark" aria-hidden="true"></span>
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
<strong>Roast Planner</strong>
</div>
<h1 id="signup-title">Create an account</h1>
+1
View File
@@ -11,6 +11,7 @@ const APP_SHELL = [
"/worksheet.css?v=__ASSET_VERSION__",
"/manifest.webmanifest?v=__ASSET_VERSION__",
"/icon.svg?v=__ASSET_VERSION__",
"/brand-mark.svg?v=__ASSET_VERSION__",
"/js/main.js?v=__ASSET_VERSION__",
"/js/api.js?v=__ASSET_VERSION__",
"/js/nav.js?v=__ASSET_VERSION__",
+45 -22
View File
@@ -752,7 +752,7 @@ export function createApp({
ok: true,
plans: (
await db.query(
"SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",
"SELECT id,plan,name,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",
[req.user.id],
)
).rows,
@@ -765,10 +765,11 @@ export function createApp({
try {
if (!req.body.plan || typeof req.body.plan !== "object")
return res.status(400).json({ ok: false, code: "bad_plan" });
const name = String(req.body.name ?? "").trim().slice(0, 120);
const p = (
await db.query(
"INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at",
[req.user.id, req.body.plan],
"INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3) RETURNING id,plan,name,created_at,updated_at",
[req.user.id, req.body.plan, name],
)
).rows[0];
res.status(201).json({ ok: true, plan: p });
@@ -783,11 +784,27 @@ export function createApp({
requireUuidParam("id"),
async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
const hasPlan = req.body.plan !== undefined;
const hasName = req.body.name !== undefined;
if (hasPlan && (!req.body.plan || typeof req.body.plan !== "object"))
return res.status(400).json({ ok: false, code: "bad_plan" });
if (!hasPlan && !hasName)
return res.status(400).json({ ok: false, code: "bad_plan" });
const existing = hasPlan && hasName
? null
: (
await db.query(
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
const plan = hasPlan ? req.body.plan : existing?.plan;
const name = hasName
? String(req.body.name ?? "").trim().slice(0, 120)
: (existing?.name ?? "");
const r = await db.query(
"UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at",
[req.body.plan, req.params.id, req.user.id],
"UPDATE roast_plans SET plan=$1,name=$2,updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING id,plan,name,updated_at",
[plan, name, req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
@@ -855,6 +872,7 @@ export function createApp({
}
const toLotRow = (row) => ({
id: row.id,
name: row.name,
origin: row.origin,
variety: row.variety,
process: row.process,
@@ -897,14 +915,16 @@ export function createApp({
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
}
const name = String(req.body.name ?? "").trim().slice(0, 120);
const row = (
await db.query(
`INSERT INTO green_bean_lots
(user_id,origin,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes)
VALUES($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) RETURNING *`,
(user_id,origin,name,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$8,$9,$10,$11,$12) RETURNING *`,
[
req.user.id,
origin,
name,
String(req.body.variety || ""),
String(req.body.process || ""),
String(req.body.producer || ""),
@@ -937,7 +957,7 @@ export function createApp({
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
const log = (
await db.query(
`SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,p.plan->'fields'->>'0.1' AS plan_title
`SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title
FROM bean_consumption c LEFT JOIN roast_plans p ON p.id=c.roast_plan_id AND p.user_id=c.user_id
WHERE c.lot_id=$1 ORDER BY c.created_at DESC`,
[req.params.id],
@@ -984,13 +1004,13 @@ export function createApp({
await db.query(
`SELECT one_change, plan_title, updated_at FROM (
SELECT plan->'afterRoast'->>'oneChange' AS one_change,
plan->'fields'->>'0.1' AS plan_title, updated_at
CASE WHEN COALESCE(name,'')<>'' THEN name ELSE plan->'fields'->>'0.1' END AS plan_title, updated_at
FROM roast_plans
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
UNION ALL
SELECT a.after->>'oneChange' AS one_change,
p.plan->'fields'->>'0.1' AS plan_title, a.updated_at
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, a.updated_at
FROM actual_roasts a
JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.user_id=$1 AND p.plan->'inventory'->>'lotId'=$2
@@ -1061,17 +1081,20 @@ export function createApp({
// computed inside the UPDATE itself (against the pre-statement initial_weight_g)
// rather than from the `existing` row read above, so a concurrent PUT can't lose
// the other's delta the way a JS-side read-then-write would.
const name =
b.name === undefined ? existing.name : String(b.name || "").trim().slice(0, 120);
const row = (
await db.query(
`UPDATE green_bean_lots SET
origin=$1, variety=$2, process=$3, producer=$4, purchase_date=$5,
remaining_weight_g = remaining_weight_g + COALESCE($6::numeric, initial_weight_g) - initial_weight_g,
initial_weight_g = COALESCE($6::numeric, initial_weight_g),
cost_total=$7, moisture_pct=$8, density_g_l=$9, notes=$10, archived=$11,
origin=$1, name=$2, variety=$3, process=$4, producer=$5, purchase_date=$6,
remaining_weight_g = remaining_weight_g + COALESCE($7::numeric, initial_weight_g) - initial_weight_g,
initial_weight_g = COALESCE($7::numeric, initial_weight_g),
cost_total=$8, moisture_pct=$9, density_g_l=$10, notes=$11, archived=$12,
updated_at=now()
WHERE id=$12 AND user_id=$13 RETURNING *`,
WHERE id=$13 AND user_id=$14 RETURNING *`,
[
origin,
name,
b.variety === undefined ? existing.variety : String(b.variety || ""),
b.process === undefined ? existing.process : String(b.process || ""),
b.producer === undefined ? existing.producer : String(b.producer || ""),
@@ -1611,7 +1634,7 @@ export function createApp({
const rows = (
await db.query(
`SELECT a.id,a.roast_plan_id,a.roaster_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
p.plan->'fields'->>'0.1' AS plan_title, m.name AS roaster_name
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, m.name AS roaster_name
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
@@ -1633,7 +1656,7 @@ export function createApp({
try {
const row = (
await db.query(
`SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, p.plan, m.name AS roaster_name
`SELECT a.*, CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, p.plan, m.name AS roaster_name
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
@@ -1657,7 +1680,7 @@ export function createApp({
const row = (
await db.query(
`SELECT a.filename, a.original_content, a.after, a.evaluation, a.parsed,
p.plan->'fields'->>'0.1' AS plan_title, a.roast_plan_id
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, a.roast_plan_id
FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
@@ -2369,7 +2392,7 @@ export function createApp({
const rows = (
await db.query(
`SELECT s.id,s.roast_plan_id,s.data,s.total_score,s.created_at,s.updated_at,
p.plan->'fields'->>'0.1' AS plan_title
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title
FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
WHERE s.user_id=$1 AND ($2::uuid IS NULL OR s.roast_plan_id=$2)
ORDER BY s.updated_at DESC`,
@@ -2423,7 +2446,7 @@ export function createApp({
try {
const row = (
await db.query(
`SELECT s.*, p.plan->'fields'->>'0.1' AS plan_title
`SELECT s.*, CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title
FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
WHERE s.id=$1 AND s.user_id=$2`,
[req.params.id, req.user.id],
@@ -2753,7 +2776,7 @@ export function createApp({
: null;
const rows = (
await db.query(
`SELECT p.id,p.plan->'fields'->>'0.1' AS title,p.updated_at,u.id AS user_id,u.email
`SELECT p.id,CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS title,p.updated_at,u.id AS user_id,u.email
FROM roast_plans p JOIN users u ON u.id=p.user_id
WHERE $1::uuid IS NULL OR p.user_id=$1
ORDER BY p.updated_at DESC`,
+3 -3
View File
@@ -57,7 +57,7 @@ const brewBody = obj({
tastingNotes: str, notes: str,
});
const lot = obj({
id: str, origin: str, variety: str, process: str, producer: str,
id: str, name: str, origin: str, variety: str, process: str, producer: str,
purchaseDate: { ...str, nullable: true }, initialWeightG: { type: "number" },
remainingWeightG: { type: "number" }, costTotal: num, moisturePct: num, densityGL: num,
notes: str, archived: bool,
@@ -93,10 +93,10 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
// ── Roast plans ──
"/api/plans": {
get: { tags: ["roast plans"], summary: "List your roast plans", responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" } }, ["plan"])), responses: { 201: ok("Created") } },
post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } },
},
"/api/plans/{id}": {
put: { tags: ["roast plans"], summary: "Update a roast plan", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" } }, ["plan"])), responses: { 200: ok("Updated"), 404: err("Not found") } },
put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["roast plans"], summary: "Delete a roast plan", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/machine-profile": { get: { tags: ["roast plans"], summary: "Learned per-user machine profile", responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
+2 -2
View File
@@ -24,13 +24,13 @@ export async function setup(env = {}, appOptions = {}) {
await db.query(
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz,avatar text);
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
INSERT INTO app_settings VALUES('signup_enabled','true');
INSERT INTO app_settings VALUES('llm_model','');
CREATE TABLE password_reset_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now());
CREATE TABLE audit_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,action text NOT NULL,target text,created_at timestamptz DEFAULT now());
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,name text NOT NULL DEFAULT '',variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
+55
View File
@@ -134,6 +134,61 @@ test("inventory: consume decrements remaining, allows negative, is idempotent pe
assert.equal(withLog.body.log[1].planTitle, "Test coffee");
});
test("inventory: custom name is created, updated, listed, and falls back cleanly when unset", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
// No name given: defaults to "" so the client's `lot.name || lot.origin` fallback applies.
const unnamed = await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila, Colombia", initialWeightG: 500 });
assert.equal(unnamed.status, 201);
assert.equal(unnamed.body.lot.name, "");
assert.equal(unnamed.body.lot.origin, "Huila, Colombia");
// Named on create.
const named = await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Yirgacheffe", name: " Spring Ethiopia ", initialWeightG: 300 });
assert.equal(named.status, 201);
assert.equal(named.body.lot.name, "Spring Ethiopia", "name is trimmed");
// Name is returned in both the list and the detail endpoints.
const list = await agent.get("/api/inventory");
const listedNamed = list.body.lots.find((l) => l.id === named.body.lot.id);
assert.equal(listedNamed.name, "Spring Ethiopia");
const listedUnnamed = list.body.lots.find((l) => l.id === unnamed.body.lot.id);
assert.equal(listedUnnamed.name, "");
const detail = await agent.get(`/api/inventory/${named.body.lot.id}`);
assert.equal(detail.body.lot.name, "Spring Ethiopia");
// Rename via PUT; other fields are left untouched (partial-update pattern).
const renamed = await agent
.put(`/api/inventory/${unnamed.body.lot.id}`)
.set("x-csrf-token", csrf)
.send({ name: "Reserve lot" });
assert.equal(renamed.status, 200);
assert.equal(renamed.body.lot.name, "Reserve lot");
assert.equal(renamed.body.lot.origin, "Huila, Colombia");
// A PUT that omits `name` leaves the existing name unchanged.
const untouched = await agent
.put(`/api/inventory/${unnamed.body.lot.id}`)
.set("x-csrf-token", csrf)
.send({ notes: "recheck moisture" });
assert.equal(untouched.body.lot.name, "Reserve lot");
// Clearing the name back out is honored explicitly.
const cleared = await agent
.put(`/api/inventory/${unnamed.body.lot.id}`)
.set("x-csrf-token", csrf)
.send({ name: "" });
assert.equal(cleared.body.lot.name, "");
});
test("inventory: consuming against another user's plan is refused", async () => {
const { app } = await setup();
const owner = request.agent(app);
+130
View File
@@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup, password } from "./helpers.js";
async function bootstrapAdmin(agent) {
const response = await agent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
assert.equal(response.status, 201);
return response.body.csrfToken;
}
test("plans: custom name is created, listed, and defaults to blank", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const unnamed = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "House blend" } } });
assert.equal(unnamed.status, 201);
assert.equal(unnamed.body.plan.name, "");
const named = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "House blend" } }, name: " Winter batch " });
assert.equal(named.status, 201);
assert.equal(named.body.plan.name, "Winter batch", "name is trimmed");
const list = (await agent.get("/api/plans")).body.plans;
assert.equal(list.find((p) => p.id === named.body.plan.id).name, "Winter batch");
assert.equal(list.find((p) => p.id === unnamed.body.plan.id).name, "");
});
test("plans: PUT name-only preserves the plan JSONB, and PUT plan-only preserves the name", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const created = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Original title", "0.4": "300" } }, name: "First name" });
const planId = created.body.plan.id;
// Name-only update: the plan JSONB is untouched.
const renamed = await agent
.put(`/api/plans/${planId}`)
.set("x-csrf-token", csrf)
.send({ name: "Second name" });
assert.equal(renamed.status, 200);
assert.equal(renamed.body.plan.name, "Second name");
assert.equal(renamed.body.plan.plan.fields["0.1"], "Original title");
assert.equal(renamed.body.plan.plan.fields["0.4"], "300");
// Plan-only update: the name is untouched.
const replanned = await agent
.put(`/api/plans/${planId}`)
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "New title", "0.4": "350" } } });
assert.equal(replanned.status, 200);
assert.equal(replanned.body.plan.name, "Second name");
assert.equal(replanned.body.plan.plan.fields["0.1"], "New title");
// Both at once.
const both = await agent
.put(`/api/plans/${planId}`)
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Third title" } }, name: "Third name" });
assert.equal(both.body.plan.name, "Third name");
assert.equal(both.body.plan.plan.fields["0.1"], "Third title");
// Neither field present: bad request, same as the pre-existing plan-required behavior.
assert.equal(
(await agent.put(`/api/plans/${planId}`).set("x-csrf-token", csrf).send({})).status,
400,
);
});
test("plans: ownership — a stranger cannot rename another user's plan", async () => {
const { app } = await setup();
const owner = request.agent(app);
const stranger = request.agent(app);
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
const created = await owner
.post("/api/plans")
.set("x-csrf-token", ownerCsrf)
.send({ plan: { fields: { 0.1: "Owner's plan" } } });
const planId = created.body.plan.id;
const hijack = await stranger
.put(`/api/plans/${planId}`)
.set("x-csrf-token", strangerCsrf)
.send({ name: "Hijacked" });
assert.equal(hijack.status, 404);
// The plan is unaffected.
const stillOwners = (await owner.get("/api/plans")).body.plans;
assert.equal(stillOwners.find((p) => p.id === planId).name, "");
});
test("plans: admin plan list title falls back to worksheet field 0.1 when no custom name is set", async () => {
const { app } = await setup();
const admin = request.agent(app);
await bootstrapAdmin(admin);
const user = request.agent(app);
const { csrf: userCsrf } = await signup(user, "[email protected]");
const named = await user
.post("/api/plans")
.set("x-csrf-token", userCsrf)
.send({ plan: { fields: { 0.1: "Worksheet title" } }, name: "Custom title" });
const unnamed = await user
.post("/api/plans")
.set("x-csrf-token", userCsrf)
.send({ plan: { fields: { 0.1: "Worksheet title only" } } });
const list = (await admin.get("/api/admin/plans")).body.plans;
assert.equal(list.find((p) => p.id === named.body.plan.id).title, "Custom title");
assert.equal(
list.find((p) => p.id === unnamed.body.plan.id).title,
"Worksheet title only",
);
});