Add field help dialog with cultivar/process reference notes
Test and deploy / test-and-deploy (push) Successful in 1m32s
Test and deploy / test-and-deploy (push) Successful in 1m32s
Adds a help-dialog UI (field-help.js/field-help-content.js) surfacing per-field guidance, expands cultivar and process reference data with cup notes, sweet-spot width, and watch-fors, and adds a Makefile for common npm tasks. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
PORT ?= 8090
|
||||
|
||||
.PHONY: all install start dev clean restart
|
||||
|
||||
all: install
|
||||
|
||||
## Install dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
## Start the server in production mode
|
||||
start:
|
||||
PORT=$(PORT) npm start
|
||||
|
||||
## Start the server in development mode (auto-restart on file changes)
|
||||
dev:
|
||||
PORT=$(PORT) npm run dev
|
||||
|
||||
## Remove node_modules and lockfile
|
||||
clean:
|
||||
rm -rf node_modules package-lock.json
|
||||
|
||||
## Restart the server
|
||||
restart: clean install start
|
||||
+200
@@ -430,6 +430,176 @@ label.filebtn input[type="file"] {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* ─── Field-help dialog ───────────────────────────────────────────────── */
|
||||
.help-dialog {
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-pop);
|
||||
width: min(680px, 94vw);
|
||||
max-height: min(82vh, 780px);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgba(27, 22, 20, 0.32);
|
||||
}
|
||||
.help-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: inherit;
|
||||
}
|
||||
.help-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.help-kicker {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember);
|
||||
}
|
||||
.help-head h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.help-body {
|
||||
padding: 14px 20px;
|
||||
overflow-y: auto;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.help-body h4 {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.help-body h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.help-body p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.help-body dl {
|
||||
margin: 0;
|
||||
}
|
||||
.help-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 6px 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.help-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.help-table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
padding: 7px 10px 5px;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
.help-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.help-table td.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.help-table tr.grp td {
|
||||
background: var(--ember-soft);
|
||||
color: var(--ember-strong);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
.help-table tr.current td {
|
||||
background: var(--ember-soft);
|
||||
box-shadow: inset 3px 0 0 var(--ember);
|
||||
}
|
||||
.help-aside {
|
||||
border-left: 3px solid var(--ember);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
background: var(--ember-soft);
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.help-aside .tag {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember-strong);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.help-aside p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.help-aside.warn {
|
||||
border-left-color: var(--warn);
|
||||
background: var(--warn-bg);
|
||||
}
|
||||
.help-aside.warn .tag {
|
||||
color: var(--warn);
|
||||
}
|
||||
.help-formula {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-attrib {
|
||||
margin: 0;
|
||||
padding: 10px 20px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.help-dialog {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin: auto 0 0;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
max-height: 88vh;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.help-btn,
|
||||
.help-dialog {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Workspace layout ────────────────────────────────────────────────── */
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
@@ -592,6 +762,36 @@ label.filebtn input[type="file"] {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.help-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ink-3);
|
||||
font: 600 9.5px/1 var(--font-ui);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.help-btn:hover,
|
||||
.help-btn:focus-visible {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember);
|
||||
background: var(--ember-soft);
|
||||
}
|
||||
.help-btn:focus-visible {
|
||||
outline: 2px solid var(--ember);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.subhead .help-btn,
|
||||
.dock-card-head .help-btn {
|
||||
margin-left: 6px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
/* ─── Teaching layer ─────────────────────────────────────────────────────
|
||||
Replaces the old per-field "?" tooltip system: a collapsible "why" panel
|
||||
per section, a permanent one-line note under only the causally-loaded
|
||||
|
||||
@@ -348,6 +348,24 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<dialog class="help-dialog" id="field-help-dialog" aria-labelledby="help-dialog-title">
|
||||
<div class="help-card">
|
||||
<div class="help-head">
|
||||
<div>
|
||||
<span class="help-kicker">Field guide</span>
|
||||
<h3 id="help-dialog-title"></h3>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-close-help aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="help-body" id="help-dialog-body"></div>
|
||||
<p class="help-attrib">
|
||||
Timing framework: Rob Hoos, <em>Cultivars: A Practical Guide for Coffee Roasters</em>.
|
||||
Temperatures, bands and machine settings: your own logged roasts on the Hottop
|
||||
KN-8828B-2K+ — they transfer to no other machine.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<form id="plan-form">
|
||||
<div class="workspace-grid">
|
||||
<div class="form-col">
|
||||
|
||||
@@ -0,0 +1,939 @@
|
||||
// Registry for the field-help dialog (see field-help.js). Each topic renders a DocumentFragment
|
||||
// of reference material — tables, worked examples, glossary — pulled from the original paper
|
||||
// worksheet (manual-roast-planner.html) that the app's why-panels don't cover. Why-panels teach
|
||||
// *why* a section works the way it does; these topics are *what are my options and what do they
|
||||
// do*, so keep prose here table-and-example-heavy rather than mechanism-heavy.
|
||||
|
||||
import {
|
||||
GROUPS,
|
||||
CULTIVARS,
|
||||
PROCESSES,
|
||||
ROAST_LEVELS,
|
||||
MACHINE,
|
||||
SANITY_BANDS,
|
||||
TAPER_TEMPLATE,
|
||||
SYMPTOM_FIXES,
|
||||
CHARGE_SETTINGS,
|
||||
YELLOW_RATIO,
|
||||
findCultivar,
|
||||
} from "/shared/reference-data.js";
|
||||
|
||||
// ---- small DOM builders --------------------------------------------------
|
||||
|
||||
function p(text) {
|
||||
const el = document.createElement("p");
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function h4(text) {
|
||||
const el = document.createElement("h4");
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function frag(...nodes) {
|
||||
const f = document.createDocumentFragment();
|
||||
for (const n of nodes) if (n) f.append(n);
|
||||
return f;
|
||||
}
|
||||
|
||||
function aside({ tag, text, warn }) {
|
||||
const el = document.createElement("div");
|
||||
el.className = warn ? "help-aside warn" : "help-aside";
|
||||
const tagEl = document.createElement("span");
|
||||
tagEl.className = "tag";
|
||||
tagEl.textContent = tag;
|
||||
el.append(tagEl, p(text));
|
||||
return el;
|
||||
}
|
||||
|
||||
function formula(text, caption) {
|
||||
const wrap = document.createDocumentFragment();
|
||||
const f = document.createElement("div");
|
||||
f.className = "help-formula";
|
||||
f.textContent = text;
|
||||
wrap.append(f);
|
||||
if (caption) {
|
||||
const cap = document.createElement("p");
|
||||
cap.textContent = caption;
|
||||
cap.style.marginTop = "-4px";
|
||||
cap.style.fontSize = "11px";
|
||||
cap.style.textAlign = "center";
|
||||
cap.style.color = "var(--ink-3)";
|
||||
wrap.append(cap);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function glossary(pairs) {
|
||||
const dl = document.createElement("dl");
|
||||
for (const [term, def] of pairs) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = term;
|
||||
dt.style.fontWeight = "700";
|
||||
dt.style.marginTop = "8px";
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = def;
|
||||
dd.style.margin = "2px 0 0";
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
return dl;
|
||||
}
|
||||
|
||||
/** cols: [{label, key, num?, render?(row)}]. rows: plain objects, or {__group: "label"} marker
|
||||
* rows for a group header spanning the full width. isCurrent(row) marks tr.current. */
|
||||
function buildTable({ cols, rows, isCurrent }) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "help-table-wrap";
|
||||
const table = document.createElement("table");
|
||||
table.className = "help-table";
|
||||
|
||||
const thead = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const c of cols) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = c.label;
|
||||
headRow.append(th);
|
||||
}
|
||||
thead.append(headRow);
|
||||
table.append(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
if (row.__group) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "grp";
|
||||
const td = document.createElement("td");
|
||||
td.colSpan = cols.length;
|
||||
td.textContent = row.__group;
|
||||
tr.append(td);
|
||||
tbody.append(tr);
|
||||
continue;
|
||||
}
|
||||
const tr = document.createElement("tr");
|
||||
if (isCurrent?.(row)) tr.classList.add("current");
|
||||
for (const c of cols) {
|
||||
const td = document.createElement("td");
|
||||
if (c.num) td.classList.add("num");
|
||||
const content = c.render ? c.render(row) : (row[c.key] ?? "");
|
||||
if (content instanceof Node) td.append(content);
|
||||
else td.textContent = content;
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
table.append(tbody);
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const cToF = (c) => Math.round((c * 9) / 5 + 32);
|
||||
|
||||
// ---- topics ---------------------------------------------------------------
|
||||
|
||||
export const HELP_TOPICS = {
|
||||
cultivar: {
|
||||
title: "Cultivar — every row, and what it tastes like",
|
||||
targets: ['label[data-fid="1.1"] .field-label', 'div[data-fid="1.2"] .field-label'],
|
||||
render(plan) {
|
||||
const match = findCultivar(plan.fields["1.1"]);
|
||||
const rows = [];
|
||||
for (const group of GROUPS) {
|
||||
rows.push({ __group: `${group.label.toUpperCase()} — ${group.tagline}` });
|
||||
for (const c of CULTIVARS.filter((c) => c.group === group.key)) rows.push(c);
|
||||
}
|
||||
const table = buildTable({
|
||||
cols: [
|
||||
{ label: "Cultivar", key: "name" },
|
||||
{ label: "First crack", key: "fcAnchor", num: true },
|
||||
{ label: "Profile", num: true, render: (c) => c.profile.join(" | ") },
|
||||
{ label: "Expect in the cup", key: "cup" },
|
||||
{
|
||||
label: "Sweet spot & what to watch",
|
||||
render: (c) => {
|
||||
if (!c.sweetSpot) return c.watch;
|
||||
const el = document.createElement("span");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = `${c.sweetSpot === "narrow" ? "Narrow" : "Wide"}. `;
|
||||
el.append(strong, document.createTextNode(c.watch));
|
||||
return el;
|
||||
},
|
||||
},
|
||||
],
|
||||
rows,
|
||||
isCurrent: (row) => !row.__group && match && row.name === match.name,
|
||||
});
|
||||
return frag(
|
||||
p(
|
||||
"Find your cultivar below. If it isn't listed, find its nearest relative on a coffee " +
|
||||
"family tree (World Coffee Research, Café Imports and Royal Coffee all publish one) " +
|
||||
"and use that group's row — the four groups exist so an unlisted cultivar still has " +
|
||||
"an answer. Profile reads charge→yellow | yellow→FC | FC→drop, for a light roast.",
|
||||
),
|
||||
table,
|
||||
aside({
|
||||
tag: "Why cultivar sets the clock",
|
||||
text:
|
||||
"Genetics that look purely physical — plant height, yield, bean size — change the cup " +
|
||||
"indirectly but powerfully. Denser, more mature seeds carry more sugar and more organic " +
|
||||
"acid, and bean size changes how heat gets in: a Maragogipe or Pacamara has so much " +
|
||||
"mass behind so little surface that the outside scorches while the inside lags. So " +
|
||||
"“roast a Gesha fast” is not mysticism: this seed browns and develops its " +
|
||||
"aromatics on a schedule set by its own chemistry, and if you take longer, the delicate " +
|
||||
"volatiles are gone by the time you drop.",
|
||||
}),
|
||||
aside({
|
||||
tag: "The label may be wrong",
|
||||
warn: true,
|
||||
text:
|
||||
"Genetic testing is expensive and rare. Cross-pollination runs roughly 20–25% even in " +
|
||||
"single-variety plots, and lines drift toward one parent over time. Treat the cultivar " +
|
||||
"on the bag as your best hypothesis, not a fact — which is exactly why the " +
|
||||
"After-the-Roast symptom table exists. And if this coffee is more than one cultivar, " +
|
||||
"the Blend section resolves it into a single anchor.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"anchor-profile": {
|
||||
title: "Anchor & profile — how one row becomes your plan",
|
||||
targets: [
|
||||
'label[data-fid="1.4"] .field-label',
|
||||
'label[data-fid="1.5"] .field-label',
|
||||
'label[data-fid="1.7"] .field-label',
|
||||
],
|
||||
render() {
|
||||
return frag(
|
||||
h4("Reading the shorthand"),
|
||||
formula(
|
||||
"4:15 | 3:15 | 1:30",
|
||||
"charge → yellow · yellow → first crack · first crack → drop",
|
||||
),
|
||||
p(
|
||||
'They add up: first crack is 4:15 + 3:15 = 7:30, end of roast is 7:30 + 1:30 = 9:00. ' +
|
||||
"That additivity is the entire trick — it makes a roast plan something you compute " +
|
||||
"rather than guess.",
|
||||
),
|
||||
h4("Use your cultivar's own row"),
|
||||
p(
|
||||
"The group headings are for finding an unlisted cultivar's nearest relative — they are " +
|
||||
"not themselves the anchor. Papayo's row already says 7:20; Pacamara's already says " +
|
||||
"7:30–9:00. Write that row's own value into FC anchor (pick a point in the range if " +
|
||||
"one is given). Applying both a group number and a “cultivar shift” on top " +
|
||||
"of it double-counts the same adjustment — the row already has it built in.",
|
||||
),
|
||||
p(
|
||||
"± Refine stays at 0 the first time you roast this coffee; it exists only to carry the " +
|
||||
"correction you wrote after cupping the previous batch.",
|
||||
),
|
||||
h4("Where ± Dev modifier comes from"),
|
||||
p(
|
||||
"It is the profile's third number compared to the 1:30 default most rows share: Pacamara " +
|
||||
"ends in 1:45 (+0:15), Pink Bourbon and Papayo end in 1:20 (−0:10), Catimor and " +
|
||||
"Sarchimor run 1:30–2:00 (+0:00 to +0:30). If your row's third number is 1:30, the " +
|
||||
"modifier is 0. Selecting a cultivar autofills this.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Cultivar", key: "cultivar" },
|
||||
{ label: "3rd number", key: "third", num: true },
|
||||
{ label: "Modifier", key: "mod", num: true },
|
||||
],
|
||||
rows: [
|
||||
{ cultivar: "Pacamara", third: "1:45", mod: "+0:15" },
|
||||
{ cultivar: "Pink Bourbon, Papayo", third: "1:20", mod: "−0:10" },
|
||||
{ cultivar: "Catimor, Sarchimor", third: "1:30–2:00", mod: "+0:00 to +0:30" },
|
||||
{ cultivar: "Everything else", third: "1:30", mod: "0" },
|
||||
],
|
||||
}),
|
||||
aside({
|
||||
tag: `Why yellow = ${YELLOW_RATIO} × FC belongs here too`,
|
||||
text:
|
||||
"Rush to yellow and the roast arrives before internal pressure has built; drag it out " +
|
||||
"and you reach first crack already too dark or with no room left for development. " +
|
||||
`${YELLOW_RATIO} places yellow in the middle of that trade-off. It is empirical, not ` +
|
||||
`derived — but it is the ratio implied by every profile row (7:30 × ${YELLOW_RATIO} = ` +
|
||||
`4:12 ≈ 4:15 · 8:45 × ${YELLOW_RATIO} = 4:54 ≈ 4:45–5:00), which is a decent ` +
|
||||
"consistency check.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"symptom-fix": {
|
||||
title: "Symptom → cause → the single number to move",
|
||||
targets: [
|
||||
'label[data-fid="1.6"] .field-label',
|
||||
"#sec-after .field.accent .field-label",
|
||||
],
|
||||
render() {
|
||||
return frag(
|
||||
p(
|
||||
"This is the feedback loop. After cupping, find the symptom below, write the fix into " +
|
||||
'"One change next batch" — and the next time you roast this coffee, carry it into ' +
|
||||
"± Refine. First cook of a coffee, ± Refine is always 0: there is no evidence yet.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "What you taste", key: "symptom" },
|
||||
{ label: "Most likely cause", key: "cause" },
|
||||
{ label: "Change this", key: "change", num: true },
|
||||
{ label: "Notes", key: "note" },
|
||||
],
|
||||
rows: SYMPTOM_FIXES,
|
||||
}),
|
||||
aside({
|
||||
tag: "Change one thing per batch — arithmetic, not patience",
|
||||
text:
|
||||
"Move two variables and there are four possible explanations for whatever the cup " +
|
||||
"does; move three and there are eight, and you cannot separate them without more " +
|
||||
'batches than you have coffee for. Every number here has one owner, so "development ' +
|
||||
'+0:15, everything else identical" is a plan you can execute and read.',
|
||||
}),
|
||||
aside({
|
||||
tag: "Before you conclude the roast was wrong",
|
||||
warn: true,
|
||||
text:
|
||||
"A cultivar's reputation is shaped by how people habitually roast it: Maragogipe was " +
|
||||
"called dull and savory for decades largely because the slow approach old machines " +
|
||||
"forced made it dull and savory. And the coffee may simply not be good — no profile " +
|
||||
"rescues a badly picked, fermented or dried lot.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"blend-method": {
|
||||
title: "Blends — one plan for more than one coffee",
|
||||
targets: [
|
||||
"#sec-blend .subhead",
|
||||
'label[data-fid="2.4"] .field-label',
|
||||
'label[data-fid="2.5"] .field-label',
|
||||
],
|
||||
render() {
|
||||
return frag(
|
||||
p(
|
||||
"A blend goes through the same drum on the same clock — you cannot hand a Bourbon " +
|
||||
"component 8:45 and an Ethiopian component 7:30 in the same batch. This resolves the " +
|
||||
"per-cultivar anchors into one anchor, and the per-process modifiers into one " +
|
||||
"modifier. If the exact split isn't on the bag, a label that says “70/30” " +
|
||||
'or "predominantly X" is telling you the share; if nothing is given, assume equal ' +
|
||||
"shares among the named cultivars rather than guessing a majority that isn't " +
|
||||
"documented.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Situation", key: "situation" },
|
||||
{ label: "What to do", key: "action" },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
situation: "Dominant component — one component ≥ 60% of the batch",
|
||||
action:
|
||||
"Use that component's first-crack anchor and processing modifier unmodified — " +
|
||||
"roast the blend as though it were that one coffee. The minority components " +
|
||||
"ride along; note their character in the cup notes rather than trying to plan " +
|
||||
"for them.",
|
||||
},
|
||||
{
|
||||
situation: "Weighted average — no component reaches 60%",
|
||||
action:
|
||||
"Convert each component's anchor to seconds, multiply by its share, and sum. Do " +
|
||||
"the same with the processing modifiers if processing differs. The result is an " +
|
||||
"anchor that exists nowhere in nature — that's fine; it's a deliberate " +
|
||||
"compromise, not an error.",
|
||||
},
|
||||
{
|
||||
situation:
|
||||
"Split-roast, blend after — components disagree by more than about a minute at " +
|
||||
"first crack and neither is dominant",
|
||||
action:
|
||||
"Roast each component separately to its own plan, weigh each roasted output, and " +
|
||||
"blend by weight afterwards. A wide split rarely converges on one profile that's " +
|
||||
"actually good for both halves. Splitting costs two batches; it buys back a plan " +
|
||||
"you aren't guessing at.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
aside({
|
||||
tag: "Worked example — weighted average",
|
||||
text:
|
||||
"55% Ethiopian-heirloom washed (anchor 7:30 = 450 s) with 45% Bourbon washed (8:45 = " +
|
||||
"525 s). Neither reaches 60%, so weight them:",
|
||||
}),
|
||||
formula(
|
||||
"0.55 × 450 + 0.45 × 525 = 483.75 s ≈ 8:04",
|
||||
"That 8:04 goes into ledger line 1 — faster than the Bourbon wants, slower than the " +
|
||||
"Ethiopian wants, and correct for neither alone. That is the nature of a compromise anchor.",
|
||||
),
|
||||
aside({
|
||||
tag: "A blend is where the cultivar table is least trustworthy",
|
||||
warn: true,
|
||||
text:
|
||||
"Community lots are rarely genetically verified, and “70/30 Caturra/Catuai” " +
|
||||
"is an exporter's estimate, not a lab result. Treat the shares as a lower-confidence " +
|
||||
"hypothesis and let the symptom table do more of the work after the first batch.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
process: {
|
||||
title: "Process — development time and drop temperature",
|
||||
targets: ['div[data-fid="3.1"] .field-label', 'label[data-fid="3.2"] .field-label'],
|
||||
render(plan) {
|
||||
const current = plan.fields["3.1"];
|
||||
return frag(
|
||||
p(
|
||||
"Cultivar told you when to hit first crack. Processing tells you how long to stay past " +
|
||||
"it, and whether to drop hotter or cooler for the same colour. The one-line version: " +
|
||||
"natural and honey lots take 15–30 seconds less development than the equivalent " +
|
||||
"washed lot, and want a slightly earlier drop.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Process", key: "label" },
|
||||
{ label: "Dev (light roast)", key: "devBand", num: true },
|
||||
{ label: "Drop temp vs. washed", key: "dropDeltaC" },
|
||||
{ label: "Why, and what it does to the cup", key: "why" },
|
||||
],
|
||||
rows: PROCESSES.map((row) =>
|
||||
row.key === "anaerobic"
|
||||
? { ...row, devBand: "−0:10 to −0:15 off its base category" }
|
||||
: row,
|
||||
),
|
||||
isCurrent: (row) => row.key === current,
|
||||
}),
|
||||
aside({
|
||||
tag: "When modifiers stack too far",
|
||||
text:
|
||||
"Bourbon anaerobic natural, light: anchor 8:45, base 1:30, natural −0:20, anaerobic " +
|
||||
"−0:10 → development 1:00, DTR 10.3% — below the 12% floor. When stacked modifiers " +
|
||||
"push a sanity check out of band, drop the smaller modifier rather than overriding " +
|
||||
"the check: taking only the anaerobic reduction (1:20) restores DTR to 13.2%.",
|
||||
}),
|
||||
aside({
|
||||
tag: "A useful piece of scepticism",
|
||||
warn: true,
|
||||
text:
|
||||
"“Washed” guarantees only that pulping, some fermentation and washing " +
|
||||
"happened at some point. Fermentation length, temperature and resident microbes vary " +
|
||||
"enormously between producers. Two washed lots from neighbouring mills can behave " +
|
||||
"differently in the drum — when a lot defies this table, the processing detail you " +
|
||||
"weren't told is a likely culprit.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"roast-level": {
|
||||
title: "Roast level — weight loss, development, drop",
|
||||
targets: [], // placed by field-help.js via a `place()` function, see below
|
||||
place() {
|
||||
const anchor = document.querySelector('#plan-form div[data-fid="4.1"]');
|
||||
const subhead = anchor?.previousElementSibling;
|
||||
return subhead?.classList.contains("subhead") ? [subhead] : [];
|
||||
},
|
||||
render(plan) {
|
||||
const current = plan.fields["4.1"];
|
||||
const rows = [
|
||||
{ label: "Under-developed", weightLossBand: "< 11%", devBand: "—", character: "Treat as a warning, not a level. Low-moisture greens occasionally land here legitimately; otherwise assume the roast didn't finish.", sentinel: true },
|
||||
...ROAST_LEVELS,
|
||||
{ label: "Too far", weightLossBand: "> 22%", devBand: "—", character: "Approaching combustion. Avoid.", sentinel: true },
|
||||
];
|
||||
return frag(
|
||||
p(
|
||||
"Roast level is the one input that is purely your decision. Everything else describes " +
|
||||
"the coffee; this describes what you want from it.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Level", key: "label" },
|
||||
{ label: "Weight loss", key: "weightLossBand", num: true },
|
||||
{ label: "Development", key: "devBand", num: true },
|
||||
{ label: "Character", key: "character" },
|
||||
],
|
||||
rows,
|
||||
isCurrent: (row) => !row.sentinel && row.key === current,
|
||||
}),
|
||||
(() => {
|
||||
const strong = document.createElement("p");
|
||||
const b = document.createElement("strong");
|
||||
b.textContent =
|
||||
"Hard ceiling: development beyond 4:30 mutes even a dark roast. Nothing good is on " +
|
||||
"the other side of that line.";
|
||||
strong.append(b);
|
||||
return strong;
|
||||
})(),
|
||||
aside({
|
||||
tag: "Why development is a bell curve, not a slider",
|
||||
text:
|
||||
"Shorter development pushes acidity up; longer pulls it down — that part is " +
|
||||
"monotonic. But enjoyment is not. Too short and the acidity has no fruit behind it; " +
|
||||
"too long and the cup goes flat. The pleasant region sits in the middle, and it is " +
|
||||
'narrower than the acidity curve suggests. This is why "just add development time" ' +
|
||||
"is not a general fix — move in 15-second steps and cup between.",
|
||||
}),
|
||||
h4("Two ways to know where you landed"),
|
||||
p(
|
||||
"Weight loss is the honest instrument: WL% = (green − roasted) ÷ green × 100 — it rolls " +
|
||||
"total time, development, drop temperature and colour into one number your scale can " +
|
||||
"measure. Colour (Agtron) is more precise if you have a meter; light roasts here " +
|
||||
"target roughly 70 whole bean / 110 ground. The two disagree when sugar content is " +
|
||||
"unusual: high-sugar lots — SL cultivars, Sidra, Papayo, many naturals — read darker " +
|
||||
"than their weight loss predicts. Trust weight loss for development, colour for " +
|
||||
"appearance, and the cup over both.",
|
||||
),
|
||||
aside({
|
||||
tag: "Where the cultivar table stops applying",
|
||||
warn: true,
|
||||
text:
|
||||
"The first-crack anchors are validated for light and medium roasts. Dark roasting " +
|
||||
"behaves as a style of its own and generally rewards a longer, more relaxed approach " +
|
||||
"— past 17% weight loss, treat the cultivar row as a lower-confidence starting point " +
|
||||
"and let cupping lead.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
moisture: {
|
||||
title: "Moisture — what the reading changes",
|
||||
targets: ['label[data-fid="5.1"] .field-label'],
|
||||
render() {
|
||||
return frag(
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Reading", key: "reading" },
|
||||
{ label: "What it does in the drum", key: "effect" },
|
||||
{ label: "Correction", key: "correction" },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
reading: "> 11.5% (wet)",
|
||||
effect:
|
||||
"Faster start — higher turning point, steeper RoR out of it — then the climb " +
|
||||
"flattens as the bean nears 100 °C, because water is absorbing energy to change " +
|
||||
"phase rather than raising temperature.",
|
||||
correction:
|
||||
"Charge slightly higher, or hold more heat through drying, to keep the same " +
|
||||
"times as a drier lot. Expect the stall; don't panic-add heat when it arrives.",
|
||||
},
|
||||
{
|
||||
reading: "9.5–11.5%",
|
||||
effect: "The healthy range. Every number in this app assumes it.",
|
||||
correction: "None.",
|
||||
},
|
||||
{
|
||||
reading: "< 9.5% (dry)",
|
||||
effect: "Less thermal ballast. Heats faster throughout and darkens more readily.",
|
||||
correction:
|
||||
"Charge lower, ease off heat sooner. Watch for a weight loss that under-reads " +
|
||||
"the visible colour.",
|
||||
},
|
||||
{
|
||||
reading: "> 12%",
|
||||
effect: "A buying problem, not a roasting problem.",
|
||||
correction:
|
||||
"Expect flavour issues and poor longevity. Roast it soon or don't buy it — " +
|
||||
"worth writing landed moisture into your supplier contract.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
p("Corrections land in ± Net correction, and rarely exceed ±0:15."),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
density: {
|
||||
title: "Density & altitude — how hard you can push",
|
||||
targets: ['label[data-fid="5.2"] .field-label', 'label[data-fid="5.3"] .field-label'],
|
||||
render() {
|
||||
return frag(
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Character", key: "character" },
|
||||
{ label: "What it means", key: "meaning" },
|
||||
{ label: "Correction", key: "correction" },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
character: "High density / high grown",
|
||||
meaning:
|
||||
"Slow maturation put more sugar and more organic acid into a tighter cell " +
|
||||
"structure. It tolerates a harder push without breaking.",
|
||||
correction:
|
||||
"You can drive it faster and hotter early. Reach for these when you want acidity.",
|
||||
},
|
||||
{
|
||||
character: "Low density / low grown",
|
||||
meaning: "Softer structure, less sugar. Push it hard and you get defects instead of speed.",
|
||||
correction:
|
||||
"Gentler heat, more airflow. Better suited to blends and darker roasts where you " +
|
||||
"want less acidity.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
aside({
|
||||
tag: "Density beats altitude",
|
||||
text:
|
||||
"Density tracks maturation rate more than altitude itself. Heavy shade and cool " +
|
||||
"temperatures can produce high-density seed at low elevation — a 250 m Galápagos lot " +
|
||||
"has measured comparable to high-grown Kenyan. If you have a density reading, believe " +
|
||||
"it over the altitude on the bag.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
screen: {
|
||||
title: "Bean size & screen — defect risks",
|
||||
targets: ['label[data-fid="5.4"] .field-label'],
|
||||
render() {
|
||||
return frag(
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Character", key: "character" },
|
||||
{ label: "Risk", key: "risk" },
|
||||
{ label: "Correction", key: "correction" },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
character: "Very large (Maragogipe, Pacamara)",
|
||||
risk:
|
||||
"Scorching, tipping and facing. Mass-to-surface ratio means the outside cooks " +
|
||||
"while the core lags, and the beans sit longer against hot metal.",
|
||||
correction:
|
||||
"Reduce batch size and raise airflow. That keeps inlet temperature down and " +
|
||||
"stops any one surface staying in contact too long.",
|
||||
},
|
||||
{
|
||||
character: "Uneven screen",
|
||||
risk: "Mixed development — small beans finish while large ones are still behind.",
|
||||
correction:
|
||||
"Smaller batch, more airflow, gentler heat. Accept a wider window at first crack.",
|
||||
},
|
||||
{
|
||||
character: "Delicate / tipping-prone (Gesha especially)",
|
||||
risk:
|
||||
"Dark scorch marks at the bean ends from excessive inlet air temperature.",
|
||||
correction:
|
||||
"Counter-intuitive but correct: to roast fast and safely, use more airflow at " +
|
||||
"lower inlet temperature rather than more heat. Same speed, less thermal shock.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
aside({
|
||||
tag: "Why “just turn up the heat” is the wrong way to go faster",
|
||||
text:
|
||||
"Raising the burner raises both conduction and convection, and the conductive share is " +
|
||||
"exactly what causes scorching and tipping. Raising airflow increases convective " +
|
||||
"transfer while lowering the temperature any single surface reaches. The safe way to " +
|
||||
"hit a 7:30 first crack on a fragile coffee is higher airflow at a lower inlet — more " +
|
||||
"energy delivered, less peak temperature at the contact point. This is the single most " +
|
||||
"useful idea in this section, and it applies to every fast-roasting cultivar.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"sweet-spot": {
|
||||
title: "Sweet spot — how much timing error the coffee forgives",
|
||||
targets: ['div[data-fid="5.5"] .field-label'],
|
||||
render() {
|
||||
const narrow = CULTIVARS.filter((c) => c.sweetSpot === "narrow");
|
||||
const wide = CULTIVARS.filter((c) => c.sweetSpot === "wide");
|
||||
const rows = [
|
||||
...narrow.map((c) => ({ name: c.name, band: "Narrow", miss: c.watch })),
|
||||
...wide.map((c) => ({ name: c.name, band: "Wide", miss: c.watch })),
|
||||
];
|
||||
return frag(
|
||||
p(
|
||||
"Narrow means a miss of ±15 seconds at first crack shows in the cup; wide means the " +
|
||||
"cultivar tolerates a range and different points in it emphasise different characters. " +
|
||||
"The calls below come from the cultivar table's own rows.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Cultivar", key: "name" },
|
||||
{ label: "Band", key: "band" },
|
||||
{ label: "What a miss does", key: "miss" },
|
||||
],
|
||||
rows,
|
||||
}),
|
||||
p("Rows not listed here don't state a band — assume medium and let the cup tell you."),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"net-correction": {
|
||||
title: "± Net correction — judgment, in seconds",
|
||||
targets: ['label[data-fid="5.6"] .field-label'],
|
||||
render() {
|
||||
return frag(
|
||||
p(
|
||||
"This is where the bean-condition observations become one signed number on ledger " +
|
||||
"line 3. It is rarely more than ±0:15, and only for an observation you can name: wet " +
|
||||
"greens (> 11.5%) may earn a few seconds later; very dry (< 9.5%) a few seconds " +
|
||||
"earlier; low density argues for gentleness, not lateness. Moisture and density never " +
|
||||
"create this number automatically — the app deliberately leaves it to you.",
|
||||
),
|
||||
aside({
|
||||
tag: "Don't buy speed with heat",
|
||||
warn: true,
|
||||
text:
|
||||
"If the reason you're tempted to correct is a large or fragile bean, the correction " +
|
||||
"usually isn't time at all — it's a smaller batch and more airflow (see Screen). " +
|
||||
"Large or fragile beans: don't buy speed with heat.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"batch-size": {
|
||||
title: "Batch size — the strongest lever you don't set",
|
||||
targets: [],
|
||||
place() {
|
||||
const input = document.querySelector('#plan-form input[name="6.4"]');
|
||||
const label = input?.closest(".ledger-row")?.querySelector(".l-label");
|
||||
return label ? [label] : [];
|
||||
},
|
||||
render() {
|
||||
return frag(
|
||||
p(
|
||||
"Batch size is the strongest correlate with first-crack time in your logged data " +
|
||||
"(r = +0.67). Your logs: 225 g lands first crack roughly 1:30–2:00 later than 200 g " +
|
||||
"at similar settings — the 226 g batches sat at 11:16 and 12:24 when nothing at 200 g " +
|
||||
"went past 10:33. Enter the correction for your green weight versus the reference " +
|
||||
"weights below.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Batch", key: "batch" },
|
||||
{ label: "Charge probe", key: "chargeC", num: true },
|
||||
{ label: "Heat", key: "heat", num: true },
|
||||
{ label: "Fan", key: "fan", num: true },
|
||||
{ label: "Observed result", key: "observed" },
|
||||
],
|
||||
rows: CHARGE_SETTINGS,
|
||||
}),
|
||||
aside({
|
||||
tag: "Charge temperature is a batch-size function",
|
||||
text:
|
||||
"Higher charge for larger batches is how the reference rows keep their timing. If " +
|
||||
"your turning point lands much past 1:05 at 200 g, you charged cool.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"milestones-ror": {
|
||||
title: "Milestones & the RoR budget — your machine's proven numbers",
|
||||
targets: [],
|
||||
place() {
|
||||
const el = document.querySelectorAll("#sec-machine .subhead")[0];
|
||||
return el ? [el] : [];
|
||||
},
|
||||
render() {
|
||||
const rows = [
|
||||
{ milestone: "Charge", median: `${MACHINE.charge.medianC} °C`, range: `${MACHINE.charge.rangeC[0]}–${MACHINE.charge.rangeC[1]} °C`, note: "The probe reading when beans go in, i.e. your preheat. Higher for larger batches." },
|
||||
{ milestone: "Turning point", median: `${MACHINE.turningPoint.medianC} °C @ ${MACHINE.turningPoint.medianTime}`, range: `${MACHINE.turningPoint.rangeC[0]}–${MACHINE.turningPoint.rangeC[1]} °C @ ${MACHINE.turningPoint.rangeTime}`, note: "Falls out of charge temperature and batch mass; you don't set it directly. A TP much past 1:05 at 200 g means you charged cool." },
|
||||
{ milestone: "Yellow", median: `${MACHINE.yellow.medianC} °C`, range: `${MACHINE.yellow.rangeC[0]}–${MACHINE.yellow.rangeC[1]} °C`, note: "Threshold-derived in your logs, not observed. Use as a rough guide and start calling it by eye — grassy smell turns to bread, colour goes straw." },
|
||||
{ milestone: "First crack", median: `${MACHINE.firstCrack.medianC} °C`, range: `${MACHINE.firstCrack.rangeC[0]}–${MACHINE.firstCrack.rangeC[1]} °C`, note: "Tight and reliable across all your lots. This is your most trustworthy single number." },
|
||||
{ milestone: "Drop (light)", median: `${MACHINE.drop.medianC} °C`, range: `${MACHINE.drop.rangeC[0]}–${MACHINE.drop.rangeC[1]} °C`, note: "All your logged roasts are light. For darker levels, build the ladder yourself: raise drop 2–3 °C, weigh, record — don't extrapolate." },
|
||||
];
|
||||
return frag(
|
||||
p(
|
||||
"These bands are from your own logged roasts — the same machine, probe and operator. " +
|
||||
"The times and ratios in this app port between machines; the temperatures do not. " +
|
||||
`Your probe reads first crack at 176–187 °C (${cToF(176)}–${cToF(187)} °F), well below ` +
|
||||
"the ~196 °C a fast, well-immersed probe would show; that doesn't make it wrong, it " +
|
||||
"makes it yours.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Milestone", key: "milestone" },
|
||||
{ label: "Median", key: "median", num: true },
|
||||
{ label: "Range", key: "range", num: true },
|
||||
{ label: "Note", key: "note" },
|
||||
],
|
||||
rows,
|
||||
}),
|
||||
p(
|
||||
"For each phase, divide the temperature you must gain by the time you have. A phase " +
|
||||
"outside its band means the plan is not reachable — change the ledger, not the roaster.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Phase", key: "phase" },
|
||||
{ label: "Machine-proven °C/min", key: "band", num: true },
|
||||
],
|
||||
rows: [
|
||||
{ phase: "TP → Yellow", band: `${MACHINE.ror.tpToYellow[0]}–${MACHINE.ror.tpToYellow[1]}` },
|
||||
{ phase: "Yellow → FC", band: `${MACHINE.ror.yellowToFc[0]}–${MACHINE.ror.yellowToFc[1]}` },
|
||||
{ phase: "FC → Drop", band: `${MACHINE.ror.fcToDrop[0]}–${MACHINE.ror.fcToDrop[1]}` },
|
||||
],
|
||||
}),
|
||||
h4("Terms"),
|
||||
glossary([
|
||||
["Turning point", "the lowest bean-probe reading after charge, where the beans stop cooling the drum and start absorbing heat"],
|
||||
["Yellow / dry end", "free moisture largely gone, colour green→straw, smell grassy→bread; call it by eye and nose"],
|
||||
["Maillard", "the browning phase between yellow and first crack — most of the sweetness, body and complexity"],
|
||||
["First crack", "audible, exothermic; internal pressure ruptures the cell structure"],
|
||||
["Development", "first crack to drop — the main acidity/sweetness lever and the only phase you can still adjust while roasting"],
|
||||
["DTR", "development ÷ total roast time"],
|
||||
["Rate of rise", "how fast bean temperature climbs, °C/min"],
|
||||
["Exotherm", "around first crack the bean briefly releases its own heat — roughly +1.4 °C/min you did not command"],
|
||||
["Second crack", "quieter, higher-pitched; dark-roast territory, past everything these numbers are validated for"],
|
||||
]),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
actuators: {
|
||||
title: "Heat & fan — the taper, and what actually moves first crack",
|
||||
targets: [],
|
||||
place() {
|
||||
const el = document.querySelectorAll("#sec-machine .subhead")[1];
|
||||
return el ? [el] : [];
|
||||
},
|
||||
render() {
|
||||
return frag(
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Point in roast", key: "point" },
|
||||
{ label: "Heat", key: "heat", num: true },
|
||||
{ label: "Fan", key: "fan", num: true },
|
||||
{ label: "Intent", key: "intent" },
|
||||
],
|
||||
rows: TAPER_TEMPLATE,
|
||||
}),
|
||||
h4("Charge settings by batch"),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Batch", key: "batch" },
|
||||
{ label: "Charge probe", key: "chargeC", num: true },
|
||||
{ label: "Heat", key: "heat", num: true },
|
||||
{ label: "Fan", key: "fan", num: true },
|
||||
{ label: "Observed result", key: "observed" },
|
||||
],
|
||||
rows: CHARGE_SETTINGS,
|
||||
}),
|
||||
aside({
|
||||
tag: "The first-cut lever",
|
||||
text:
|
||||
"When you make the first heat cut is your main first-crack lever — later cut, earlier " +
|
||||
"crack. Holding 100% to ~3:00 gave 7:55; cutting at ~1:00 gave 9:00. Steps of 3–5% " +
|
||||
"beat single large ones, and the machine has roughly a 60-second dead time: what you " +
|
||||
"change now shows up about a minute from now.",
|
||||
}),
|
||||
aside({
|
||||
tag: "What your logs will NOT tell you",
|
||||
warn: true,
|
||||
text:
|
||||
'"More heat before yellow gives an earlier first crack" does not hold in your data — ' +
|
||||
"heat delivered before yellow correlates positively with first-crack time (r = +0.43) " +
|
||||
"because it's partly a proxy for elapsed time; average heat rate shows almost none " +
|
||||
"(r = +0.20). The honest correlates are batch size (+0.67) and time to yellow (+0.59) " +
|
||||
"— both descriptive, neither causal. The taper is a starting shape, not a validated " +
|
||||
"control law: for a real lever, vary only the time of the first heat cut across three " +
|
||||
"batches of the same coffee.",
|
||||
}),
|
||||
h4("Fan, measured causally"),
|
||||
p(
|
||||
`Isolated fan steps with the burner held steady give roughly ${MACHINE.fanEffectCPerMinPer10Pct} ` +
|
||||
"°C/min of RoR per +10% fan. Only 5 clean windows across 4 roasts — but it is the one " +
|
||||
"number here from a controlled comparison rather than a correlation.",
|
||||
),
|
||||
h4("The plant model"),
|
||||
formula("RoR (°C/min) = 0.0903 × (0.988·H + 0.215·F + 167.0 − BT)"),
|
||||
formula("H = (RoR ÷ 0.0903 − 0.215·F − 167.0 + BT) ÷ 0.988"),
|
||||
p("H = heat %, F = fan %, BT = bean temp °C; above 178 °C add ~+1.4 °C/min for the exotherm."),
|
||||
p(
|
||||
"Treat it as a sanity check on direction and rough magnitude, not a setpoint calculator: " +
|
||||
"it carries a ±5 °C band, a 60-second dead time, was fitted on light roasts of " +
|
||||
"200–226 g, and its fan term's sign is wrong for planning (fitted from closed-loop " +
|
||||
"data — use the −1.3 figure instead). Where the equation and your logs disagree, " +
|
||||
"believe your logs.",
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
"sanity-checks": {
|
||||
title: "Sanity checks — what a failure is telling you",
|
||||
targets: [".checks-card .dock-card-head h2"],
|
||||
render() {
|
||||
return frag(
|
||||
p(
|
||||
"Four checks stand between an arithmetically valid plan and a roastable one. A fail " +
|
||||
"sends you back to the ledger — never to the machine.",
|
||||
),
|
||||
buildTable({
|
||||
cols: [
|
||||
{ label: "Check", key: "check" },
|
||||
{ label: "Compute", key: "compute" },
|
||||
{ label: "Should be", key: "should", num: true },
|
||||
{ label: "What a failure means", key: "meaning" },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
check: "Drying share",
|
||||
compute: "yellow ÷ drop",
|
||||
should: `${SANITY_BANDS.drying.lo}–${SANITY_BANDS.drying.hi}%`,
|
||||
meaning:
|
||||
"High → yellow is late and you are spending the roast drying instead of " +
|
||||
"browning; Maillard gets starved. Low → you rushed to yellow and the internal " +
|
||||
"pressure never built.",
|
||||
},
|
||||
{
|
||||
check: "Maillard share",
|
||||
compute: "maillard ÷ drop",
|
||||
should: `${SANITY_BANDS.maillard.lo}–${SANITY_BANDS.maillard.hi}%`,
|
||||
meaning:
|
||||
"Low → too small a browning window for sweetness and complexity to develop. " +
|
||||
"Usually the mirror image of a drying share that ran long.",
|
||||
},
|
||||
{
|
||||
check: "Development ratio",
|
||||
compute: "dev ÷ drop",
|
||||
should: `${SANITY_BANDS.dtr.lo}–${SANITY_BANDS.dtr.hi}% (light: 12–17)`,
|
||||
meaning:
|
||||
"Below 12% → likely under-developed whatever the clock says. Expect sharp, " +
|
||||
"astringent acidity with no fruit behind it.",
|
||||
},
|
||||
{
|
||||
check: "Development ceiling",
|
||||
compute: "dev",
|
||||
should: "< 4:30",
|
||||
meaning: "Over 4:30 mutes even a dark roast. There is nothing useful on the other side of that line.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
aside({
|
||||
tag: "Worked check — when modifiers stack",
|
||||
text:
|
||||
"Bourbon anaerobic natural, light: anchor 8:45, base 1:30, natural −0:20, anaerobic " +
|
||||
"−0:10 → development 1:00, DTR 10.3% — below the 12% floor. When stacked modifiers " +
|
||||
"push you out of band, drop the smaller one rather than overriding the check.",
|
||||
}),
|
||||
aside({
|
||||
tag: "Your own history runs outside these bands — read before dismissing",
|
||||
warn: true,
|
||||
text:
|
||||
"Across your 14 logged roasts the measured shares are drying 65%, Maillard 25%, DTR " +
|
||||
"10.4% — all outside the bands, all in the same direction. Drying and Maillard are " +
|
||||
"partly an artifact: Artisan computes the dry end from a temperature threshold, not " +
|
||||
"an observed colour change, which inflates drying and deflates Maillard in exactly " +
|
||||
"this pattern — call yellow by eye and smell for the next three batches before " +
|
||||
"concluding your roasts are structurally wrong. DTR is not an artifact: both first " +
|
||||
"crack and drop are marked events. Median development 1:00 is defensible for the " +
|
||||
"naturals that dominate your logs, but 0:45–0:52 is below the floor for any roast " +
|
||||
"level; if those batches cupped sharp or thin, that is the first number to move.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// Field-level "?" reference dialogs. Sibling to why-panels.js: why-panels are always-visible
|
||||
// section prose ("why this works"); this is an on-demand, per-field reference library ("what are
|
||||
// my options"). See FIELD_HELP_SPEC.md (deleted after implementation) for the design rationale.
|
||||
|
||||
import { HELP_TOPICS } from "./field-help-content.js";
|
||||
|
||||
export function initFieldHelp({ getPlan }) {
|
||||
const dialog = document.getElementById("field-help-dialog");
|
||||
if (!dialog) return;
|
||||
const titleEl = document.getElementById("help-dialog-title");
|
||||
const bodyEl = document.getElementById("help-dialog-body");
|
||||
let lastTrigger = null;
|
||||
|
||||
function openTopic(key, trigger) {
|
||||
const topic = HELP_TOPICS[key];
|
||||
if (!topic) return;
|
||||
lastTrigger = trigger;
|
||||
titleEl.textContent = topic.title;
|
||||
bodyEl.replaceChildren(topic.render(getPlan()));
|
||||
bodyEl.scrollTop = 0;
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
function makeButton(key, title) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "help-btn";
|
||||
btn.dataset.helpTopic = key;
|
||||
btn.setAttribute("aria-label", `Field guide: ${title.split(" — ")[0]}`);
|
||||
btn.setAttribute("aria-haspopup", "dialog");
|
||||
btn.textContent = "?";
|
||||
// Several triggers live inside <label> elements — stop the click from also
|
||||
// focusing/toggling the label's control (radio chips especially).
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openTopic(key, btn);
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
for (const [key, topic] of Object.entries(HELP_TOPICS)) {
|
||||
const containers = [];
|
||||
for (const sel of topic.targets ?? [])
|
||||
containers.push(...document.querySelectorAll(`#plan-form ${sel}`));
|
||||
if (topic.place) containers.push(...topic.place());
|
||||
for (const container of containers) container.append(makeButton(key, topic.title));
|
||||
}
|
||||
|
||||
// Deferred: a click that closes the dialog (the ✕ button, or a backdrop click) also runs
|
||||
// the browser's own click-focuses-the-target step, which can otherwise land after — and
|
||||
// override — a synchronous focus() call made while handling that same click.
|
||||
function returnFocus() {
|
||||
const trigger = lastTrigger;
|
||||
setTimeout(() => trigger?.focus(), 0);
|
||||
}
|
||||
|
||||
dialog.querySelector("[data-close-help]")?.addEventListener("click", () => {
|
||||
dialog.close();
|
||||
returnFocus();
|
||||
});
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
dialog.close();
|
||||
returnFocus();
|
||||
}
|
||||
});
|
||||
// Covers every other close path (Esc, or dialog.close() called directly) — native <dialog>
|
||||
// already restores focus to the pre-showModal() element on close in current browsers; this
|
||||
// listener is the explicit fallback for engines where that isn't reliable.
|
||||
dialog.addEventListener("close", returnFocus);
|
||||
}
|
||||
+26
-1
@@ -5,7 +5,12 @@ import {
|
||||
formatSigned,
|
||||
parseRangeMidpoint,
|
||||
} from "/shared/time.js?v=__ASSET_VERSION__";
|
||||
import { CULTIVARS, findCultivar } from "/shared/reference-data.js?v=__ASSET_VERSION__";
|
||||
import {
|
||||
CULTIVARS,
|
||||
findCultivar,
|
||||
findProcess,
|
||||
findRoastLevel,
|
||||
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
|
||||
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__";
|
||||
import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__";
|
||||
import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
|
||||
@@ -14,6 +19,7 @@ import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
|
||||
import { protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
|
||||
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
|
||||
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
||||
@@ -597,6 +603,22 @@ function cultivarAutofill(name) {
|
||||
renderFormFromPlan();
|
||||
}
|
||||
|
||||
// Mirrors cultivarAutofill(): picking a process/roast level should feed the same reference
|
||||
// numbers into the ledger that selecting a cultivar does, not just record the choice as text.
|
||||
function processAutofill(key) {
|
||||
const row = findProcess(key);
|
||||
if (!row) return;
|
||||
setValueForName("3.2", formatSigned(row.devModS));
|
||||
renderFormFromPlan();
|
||||
}
|
||||
|
||||
function roastLevelAutofill(key) {
|
||||
const row = findRoastLevel(key);
|
||||
if (!row) return;
|
||||
setValueForName("4.3", formatDuration(parseRangeMidpoint(row.devBand)));
|
||||
renderFormFromPlan();
|
||||
}
|
||||
|
||||
function wireCultivarDatalist() {
|
||||
const list = document.getElementById("cultivar-list");
|
||||
list.replaceChildren(
|
||||
@@ -619,6 +641,8 @@ function wireForm() {
|
||||
);
|
||||
if (el.name === "1.1") cultivarAutofill(el.value);
|
||||
if (el.name === "2.1") updateBlendVisibility(el.value);
|
||||
if (el.name === "3.1") processAutofill(el.value);
|
||||
if (el.name === "4.1") roastLevelAutofill(el.value);
|
||||
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
|
||||
recompute();
|
||||
});
|
||||
@@ -1010,6 +1034,7 @@ async function init() {
|
||||
renderActuators();
|
||||
wireCultivarDatalist();
|
||||
wireWhyPanels();
|
||||
initFieldHelp({ getPlan: () => state.plan });
|
||||
renderBandRanges();
|
||||
renderFormFromPlan();
|
||||
wireForm();
|
||||
|
||||
@@ -16,6 +16,8 @@ const APP_SHELL = [
|
||||
"/js/nav.js?v=__ASSET_VERSION__",
|
||||
"/js/toast.js?v=__ASSET_VERSION__",
|
||||
"/js/why-panels.js?v=__ASSET_VERSION__",
|
||||
"/js/field-help.js?v=__ASSET_VERSION__",
|
||||
"/js/field-help-content.js?v=__ASSET_VERSION__",
|
||||
"/js/lot-picker.js?v=__ASSET_VERSION__",
|
||||
"/js/prefill-ui.js?v=__ASSET_VERSION__",
|
||||
"/js/alog-ui.js?v=__ASSET_VERSION__",
|
||||
|
||||
+162
-35
@@ -1,6 +1,7 @@
|
||||
// Browser-safe. Ported from /Users/shane/dev/hope_roaster/roast-planner/manual-roast-planner.html
|
||||
// (Step 1 cultivar table, Step 3 processing table, Step 4 roast-level table, Step 8 machine bands,
|
||||
// Step 7 sanity bands). Keep this the single source of truth — don't retype these numbers elsewhere.
|
||||
// Step 7 sanity bands, Step 9 charge settings, Step 12 symptom table). Keep this the single
|
||||
// source of truth — don't retype these numbers elsewhere.
|
||||
|
||||
import { parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||||
|
||||
@@ -8,37 +9,99 @@ export const YELLOW_RATIO = 0.56;
|
||||
const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share
|
||||
|
||||
export const GROUPS = [
|
||||
{ key: "typica", label: "Typica", fcAnchor: "7:30-8:00" },
|
||||
{ key: "bourbon", label: "Bourbon", fcAnchor: "8:30-9:30" },
|
||||
{ key: "ethiopian", label: "Ethiopian", fcAnchor: "7:20-7:30" },
|
||||
{ key: "hybrid", label: "Timor Hybrid", fcAnchor: "follows parent" },
|
||||
{ key: "typica", label: "Typica", fcAnchor: "7:30-8:00", tagline: "Fast to first crack, 7:30–8:00. Floral, creamy body, caramel, balanced." },
|
||||
{ key: "bourbon", label: "Bourbon", fcAnchor: "8:30-9:30", tagline: "Slow to first crack, 8:30–9:30. Red fruit runs through all of them." },
|
||||
{ key: "ethiopian", label: "Ethiopian", fcAnchor: "7:20-7:30", tagline: "Fastest, 7:20–7:30. Perfumed florality, sparkling citrus acidity." },
|
||||
{ key: "hybrid", label: "Timor Hybrid", fcAnchor: "follows parent", tagline: "No group timing — follow whichever parent dominates the cross." },
|
||||
];
|
||||
|
||||
// profile = [charge->yellow, yellow->FC, FC->drop], for a light roast. devModS is derived below.
|
||||
// cup = "Expect in the cup"; watch = "Sweet spot & what to watch"; sweetSpot set only where the
|
||||
// worksheet's watch column opens with a bolded Narrow/Wide call.
|
||||
export const CULTIVARS = [
|
||||
{ name: "Typica", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Maragogipe", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Pache", group: "typica", fcAnchor: "7:30-8:00", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Pacamara", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15", "3:15-3:45", "1:45"] },
|
||||
{ name: "SL-34", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Catuai", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15-5:00", "3:15-4:00", "1:30"] },
|
||||
{ name: "Typica", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: "narrow",
|
||||
cup: "Orange citrus, floral, caramel; medium body",
|
||||
watch: "Slower → papery, starchy, thin. Faster than 7:20 → raw, aggressive acidity." },
|
||||
{ name: "Maragogipe", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: "wide",
|
||||
cup: "Tropical fruit, cherry, fruit punch, baking spice, creamy",
|
||||
watch: "Huge bean → scorching, tipping, facing. Smaller batch + more airflow." },
|
||||
{ name: "Pache", group: "typica", fcAnchor: "7:30-8:00", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: null,
|
||||
cup: "Apple/pear or grape, floral, baking spice, milk chocolate",
|
||||
watch: "Tolerates the slower end well. Don't go faster than 7:30." },
|
||||
{ name: "Pacamara", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15", "3:15-3:45", "1:45"],
|
||||
sweetSpot: null,
|
||||
cup: "Sharp citrus acidity, baking spice, red fruit, nutty/earthy",
|
||||
watch: "Needs the longest development here to tame acidity. Naturals 1:15–1:30. Big bean → same defect risks as Maragogipe." },
|
||||
{ name: "SL-34", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: null,
|
||||
cup: "Sweet citrus, berry/tropical, strong florality, very sweet",
|
||||
watch: "Darkens easily — same profile lands ~5–10 Agtron points darker. Taper heat hard late. Watch for post-FC RoR crash." },
|
||||
{ name: "Catuai", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15-5:00", "3:15-4:00", "1:30"],
|
||||
sweetSpot: "wide",
|
||||
cup: "Melon, citrus, syrupy, mild caramel, occasional red fruit",
|
||||
watch: "Very wide — the forgiving one. Fast = Typica-like and floral; slow = Caturra-like with chocolate. Good coffee to learn on." },
|
||||
|
||||
{ name: "Bourbon", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] },
|
||||
{ name: "Caturra", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] },
|
||||
{ name: "Pacas", group: "bourbon", fcAnchor: "8:45", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] },
|
||||
{ name: "SL-28", group: "bourbon", fcAnchor: "8:00-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] },
|
||||
{ name: "Bourbon", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"],
|
||||
sweetSpot: null,
|
||||
cup: "Deep cherry, caramel, almond, nutty, full body",
|
||||
watch: "Some lots want a full 9:00; others read rough between 8:30–9:00. Start 8:45 and move by lot." },
|
||||
{ name: "Caturra", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"],
|
||||
sweetSpot: null,
|
||||
cup: "Balanced caramel, mild acidity, red fruit, nutty",
|
||||
watch: "Development is shorter than instinct suggests. Resist stretching it." },
|
||||
{ name: "Pacas", group: "bourbon", fcAnchor: "8:45", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"],
|
||||
sweetSpot: "narrow",
|
||||
cup: "Red fruit, caramel, chocolate, nutty, rounded body",
|
||||
watch: "Outside the band a distinct minerality shows up. Don't push it faster." },
|
||||
{ name: "SL-28", group: "bourbon", fcAnchor: "8:00-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"],
|
||||
sweetSpot: null,
|
||||
cup: "Red currant, strawberry, passionfruit, floral, intense sweetness",
|
||||
watch: "Belongs with Bourbon despite the Kenyan-fast reputation. A 5:00 yellow / 9:00 FC made it thin and starchy. Naturals do well pushed to 8:00 with 1:15 dev." },
|
||||
|
||||
{ name: "Ethiopian Heirloom", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Gesha", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Pink Bourbon", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:20"] },
|
||||
{ name: "Sidra", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
||||
{ name: "Papayo", group: "ethiopian", fcAnchor: "7:20", profile: ["4:20", "3:00", "1:20"] },
|
||||
{ name: "Ethiopian Heirloom", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: null,
|
||||
cup: "Washed: citrus + caramel. Natural: strawberry/blueberry, syrupy, huge sweetness",
|
||||
watch: "Catch-all label, so let the cup steer. Hollow/papery → walk FC out toward 8:00 then 9:00. Muddled/savory → speed up." },
|
||||
{ name: "Gesha", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: "narrow",
|
||||
cup: "Jasmine, crisp florals, citrus, sweet",
|
||||
watch: "Too slow → savory, caramel-dominant, florals gone. Tipping-prone: keep inlet air cooler and use more airflow to go fast, not more heat." },
|
||||
{ name: "Pink Bourbon", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:20"],
|
||||
sweetSpot: "narrow",
|
||||
cup: "Salt-water taffy / cotton candy sweetness, citrus, tropical, perfumed floral",
|
||||
watch: "Not a Bourbon at all — genetically an Ethiopian landrace, so roast it fast. Even 8:00 turns it savory. Darkens quickly." },
|
||||
{ name: "Sidra", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"],
|
||||
sweetSpot: "wide",
|
||||
cup: "Peach, mango, tropical, floral, bright syrupy acidity",
|
||||
watch: "Hard to ruin. Fast keeps florals vivid; 8:00 also works. Keep heat low through development or it darkens." },
|
||||
{ name: "Papayo", group: "ethiopian", fcAnchor: "7:20", profile: ["4:20", "3:00", "1:20"],
|
||||
sweetSpot: "narrow",
|
||||
cup: "Mango, passionfruit, cinnamon, thick syrupy body",
|
||||
watch: "First crack is very quiet — you may have to call it by smell. Darkens fast; if in doubt shorten development." },
|
||||
|
||||
{ name: "Castillo", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:00", "1:30"] },
|
||||
{ name: "Tabi", group: "hybrid", fcAnchor: "8:00", profile: ["4:15", "3:45", "1:30"] },
|
||||
{ name: "H1 / Centroamericano", group: "hybrid", fcAnchor: "7:00", profile: ["4:15", "2:45", "1:30"] },
|
||||
{ name: "Catimor", group: "hybrid", fcAnchor: "8:30", profile: ["4:45", "4:15", "1:30-2:00"] },
|
||||
{ name: "Sarchimor", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:15", "1:30-2:00"] },
|
||||
{ name: "Castillo", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:00", "1:30"],
|
||||
sweetSpot: null,
|
||||
cup: "High citrus acidity, caramel, chocolate, hint of mint/bay or herbal",
|
||||
watch: "Follows Caturra. Slower is the fix for herbaceousness — FC before 8:15 leaves it in; 9:00 removes it. Darker/espresso: stretch development further to mute acidity." },
|
||||
{ name: "Tabi", group: "hybrid", fcAnchor: "8:00", profile: ["4:15", "3:45", "1:30"],
|
||||
sweetSpot: "narrow",
|
||||
cup: "Orange, melon, tropical/deep fruit, botanical, baking spice, perfumed floral",
|
||||
watch: "Centred on 8:00. Slower → savory + herbaceous; faster → papery and thin." },
|
||||
{ name: "H1 / Centroamericano", group: "hybrid", fcAnchor: "7:00", profile: ["4:15", "2:45", "1:30"],
|
||||
sweetSpot: null,
|
||||
cup: "Red fruit / pomegranate / passionfruit, cedar-like woody, nutty, caramel",
|
||||
watch: "The Rume Sudan parent wins: fastest in the whole set. Speed is what suppresses the savory/herbaceous notes — the opposite of Castillo." },
|
||||
{ name: "Catimor", group: "hybrid", fcAnchor: "8:30", profile: ["4:45", "4:15", "1:30-2:00"],
|
||||
sweetSpot: null,
|
||||
cup: "Sharp acidity, herbal/woody/vegetal, caramel, red fruit",
|
||||
watch: "Caturra parentage → slow. Before 8:30 expect papery and dry. Naturals and honeys are far better than washed." },
|
||||
{ name: "Sarchimor", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:15", "1:30-2:00"],
|
||||
sweetSpot: null,
|
||||
cup: "Pleasant acidity, caramel, mild herbal/botanical/woody",
|
||||
watch: "Villa Sarchi parentage → slow, same as Catimor. Generally smoother; honey-processed lots are the pick." },
|
||||
].map((c) => ({
|
||||
...c,
|
||||
devModS: parseRangeMidpoint(c.profile[2]) - DEFAULT_DEV_THIRD_S,
|
||||
@@ -59,11 +122,16 @@ export function findGroup(key) {
|
||||
}
|
||||
|
||||
export const PROCESSES = [
|
||||
{ key: "washed", label: "Washed", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" },
|
||||
{ key: "yellow-honey", label: "Yellow / white honey", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" },
|
||||
{ key: "red-black-honey", label: "Red / black honey", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" },
|
||||
{ key: "natural", label: "Natural", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" },
|
||||
{ key: "anaerobic", label: "Anaerobic / CM", devBand: "-0:10 to -0:15 further", devModS: -30, dropDeltaC: "lower still" },
|
||||
{ key: "washed", label: "Washed", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline",
|
||||
why: "Fermentation and washing strip the mucilage before drying, and the glucose and fructose are largely metabolised away during the first day on the beds. Less free sugar → slower browning → you can afford more time and heat. Cleanest, most acid-forward, most citrus-clear cup." },
|
||||
{ key: "yellow-honey", label: "Yellow / white honey", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline",
|
||||
why: "Little mucilage left; behaves essentially like a washed. Treat it as one." },
|
||||
{ key: "red-black-honey", label: "Red / black honey", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4",
|
||||
why: "Enough mucilage stays on to behave essentially like a natural. Treat it as one." },
|
||||
{ key: "natural", label: "Natural", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4",
|
||||
why: "The seed keeps far more free fructose and glucose, and fructose browns faster than sucrose does. Same profile, darker result. Shorten development and drop earlier or you overshoot the colour. Heaviest body, most overt fruit and ferment." },
|
||||
{ key: "anaerobic", label: "Anaerobic / CM", devBand: "-0:10 to -0:15 further", devModS: -30, dropDeltaC: "lower still",
|
||||
why: "Extended low-oxygen fermentation raises lactic acid, chlorogenic acids and trigonelline and drops glucose. These lots are volatile-rich and fragile. An anaerobic washed sits around 1:15–1:20, not 1:20–1:45." },
|
||||
];
|
||||
|
||||
export function findProcess(key) {
|
||||
@@ -71,11 +139,16 @@ export function findProcess(key) {
|
||||
}
|
||||
|
||||
export const ROAST_LEVELS = [
|
||||
{ key: "light", label: "Light", weightLossBand: "11-13%", devBand: "1:15-2:00" },
|
||||
{ key: "light-medium", label: "Light-medium", weightLossBand: "13-14%", devBand: "2:00" },
|
||||
{ key: "medium", label: "Medium", weightLossBand: "14-16%", devBand: "2:00-3:00" },
|
||||
{ key: "dark", label: "Dark", weightLossBand: "17-18%", devBand: "3:00-4:00" },
|
||||
{ key: "very-dark", label: "Very dark", weightLossBand: "19-21%", devBand: "3:30-4:30" },
|
||||
{ key: "light", label: "Light", weightLossBand: "11-13%", devBand: "1:15-2:00",
|
||||
character: "Almost without exception light-roast territory. Origin and cultivar character at maximum — every cultivar profile in the reference is written for this." },
|
||||
{ key: "light-medium", label: "Light-medium", weightLossBand: "13-14%", devBand: "2:00",
|
||||
character: "The transition. Acidity softening, sweetness filling in." },
|
||||
{ key: "medium", label: "Medium", weightLossBand: "14-16%", devBand: "2:00-3:00",
|
||||
character: "Less acidity, fewer delicate notes; caramel, nuts, chocolate take over." },
|
||||
{ key: "dark", label: "Dark", weightLossBand: "17-18%", devBand: "3:00-4:00",
|
||||
character: "Roast character becomes dominant: smoke, dark chocolate, woody." },
|
||||
{ key: "very-dark", label: "Very dark", weightLossBand: "19-21%", devBand: "3:30-4:30",
|
||||
character: "Roast notes dominate in ways most people find unpleasant." },
|
||||
];
|
||||
|
||||
export function findRoastLevel(key) {
|
||||
@@ -117,3 +190,57 @@ export const TAPER_TEMPLATE = [
|
||||
{ point: "Development", heat: "55-65%", fan: "80%", intent: "Small trims only." },
|
||||
{ point: "Drop", heat: "0%", fan: "100%", intent: "Cool fast; log the drop time." },
|
||||
];
|
||||
|
||||
// Step 12 — full symptom -> cause -> fix table.
|
||||
export const SYMPTOM_FIXES = [
|
||||
{ symptom: "Papery, thin, starchy, hollow, dry",
|
||||
cause: "Reached first crack too fast for this cultivar. Not enough time under pressure before the structure ruptured.",
|
||||
change: "First crack +0:30",
|
||||
note: "The classic symptom of running a Bourbon-group coffee on Typica timing." },
|
||||
{ symptom: "Savory, muddled, vegetal, caramel-dominant, florals gone",
|
||||
cause: "Reached first crack too slowly. Delicate aromatics were spent before you got there.",
|
||||
change: "First crack −0:30",
|
||||
note: "Exception: Castillo, Catimor and Sarchimor lose herbaceousness by going slower — move first crack the other way." },
|
||||
{ symptom: "Sharp, astringent, acidity with no fruit behind it",
|
||||
cause: "Development too short. Organic acids never got the time to resolve.",
|
||||
change: "Development +0:15",
|
||||
note: "Check DTR first — under 12% almost always confirms this." },
|
||||
{ symptom: "Flat, dull, no acidity, uninteresting",
|
||||
cause: "Development too long — you went past the peak of the bell curve.",
|
||||
change: "Development −0:15",
|
||||
note: "If development is already short, suspect a bake instead: look for a flat RoR stretch." },
|
||||
{ symptom: "Baked — flat, papery, no sweetness, no clear defect",
|
||||
cause: "Rate of rise flattened or crashed somewhere in the roast. The bean sat at temperature without progressing.",
|
||||
change: "Fix the RoR shape",
|
||||
note: "Smaller, earlier heat cuts. Nothing after the drop rescues a bake." },
|
||||
{ symptom: "Darker than the weight loss predicts",
|
||||
cause: "High-sugar coffee — SL cultivars, Sidra, Papayo, most naturals. More free fructose means faster browning.",
|
||||
change: "Drop 3–5 °C earlier",
|
||||
note: "Cut heat harder through development; re-baseline colour for this lot." },
|
||||
{ symptom: "Sour and roasty at the same time",
|
||||
cause: "Uneven roast — small beans finished while large ones lagged.",
|
||||
change: "Smaller batch",
|
||||
note: "Raise airflow too. Check the screen size distribution of the green." },
|
||||
{ symptom: "Tipping — dark scorch at bean ends",
|
||||
cause: "Inlet air temperature too high.",
|
||||
change: "Lower charge, raise fan",
|
||||
note: "Gesha is especially prone. More airflow at lower inlet keeps the speed without the damage." },
|
||||
{ symptom: "Scorching / facing — flat burnt patches",
|
||||
cause: "Drum surface too hot, or batch too large to keep beans moving.",
|
||||
change: "Lower charge temp",
|
||||
note: "Reduce batch size for large-bean cultivars — Maragogipe and Pacamara especially." },
|
||||
{ symptom: "Blend tastes like only one component",
|
||||
cause: "The dominant-component method is working as designed — you roasted for the majority share.",
|
||||
change: "Re-weight the blend",
|
||||
note: "If the minority component should show more, move to a weighted average, or split-roast and adjust the blend ratio after cupping each half separately." },
|
||||
];
|
||||
|
||||
// Step 9 — charge probe & starting settings by batch, from the operator's own logs.
|
||||
export const CHARGE_SETTINGS = [
|
||||
{ batch: "200 g, fast target (FC 7:30–8:15)", chargeC: "133–163 °C", heat: "100%", fan: "30–50%",
|
||||
observed: "Four logged roasts landed first crack 7:55–8:13 from here, all holding full heat ~3:00." },
|
||||
{ batch: "200 g, standard (FC 8:45–9:30)", chargeC: "134–182 °C", heat: "89–100%", fan: "40–50%",
|
||||
observed: "Your default; first reduction comes earlier, around 1:00–2:00." },
|
||||
{ batch: "225 g (FC 9:30–11:15)", chargeC: "187–195 °C", heat: "92–100%", fan: "20–30%",
|
||||
observed: "Larger mass pushes everything later — batch size is the strongest correlate with first-crack time in your data." },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user