Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s

This commit is contained in:
snowspeeder committed 2026-09-05 14:56:11 -04:00
1 parent e9f2393c96
commit 824a964f9d
30 files changed
+1721 -908

No files matched your search

+4
View File
@@ -3373,3 +3373,7 @@ select.f {
width: 100%;
display: block;
}
/* Historical machine bands and pace multipliers no longer guide planning. */
#sec-machine .band-track, .ledger-pace { display:none !important; }
#sec-machine .milestone-row { grid-template-columns:1fr 90px 110px; }
+16 -12
View File
@@ -10,7 +10,6 @@
<link rel="manifest" href="/manifest.webmanifest?v=__ASSET_VERSION__" />
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg?v=__ASSET_VERSION__" />
<link rel="stylesheet" href="/worksheet.css" media="print" />
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
</head>
<body>
@@ -255,8 +254,7 @@
<button class="primary-btn" type="submit" id="chat-send">Send</button>
</form>
<p class="field-note">
Answers are grounded in this plan's numbers, your learned pace, and
the roaster behavior learned from your uploaded roasts.
Answers use this plan’s inputs and calculated milestones. Roaster history does not modify the plan.
</p>
</div>
</aside>
@@ -402,8 +400,7 @@
<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.
Temperatures are illustrative planning references, not universal targets. Enter your own readings; preheat setpoint and bean temperature are different measurements.
</p>
</div>
</dialog>
@@ -886,14 +883,21 @@
So the rule is: decline steadily from the
post-turning-point peak, through small frequent heat cuts
rather than a few big ones, and stay ahead of the
machine's roughly 60-second lag — what you change now
shows up a minute from now, so cut <em>before</em> the
machine’s response delay — changes take time to affect the bean, so cut <em>before</em> the
exotherm, gently. If a phase would need a rate outside the
proven bands to hit its milestone, the plan is not
illustrative ranges to hit its milestone, the plan is not
reachable — fix the ledger, not the roaster.
</p>
</details>
<div class="subhead">Temperatures &amp; rate-of-rise</div>
<p class="field-note">Blank fields use an illustrative curve: charge 392°F, yellow 320°F, first crack 392°F. These are not preheat settings. First crack must be heard; enter your own probe readings to replace the illustration. Drop is derived from development time and a declining planned rise.</p>
<div class="field-grid">
<label class="field">Preheat setpoint<input class="field-input" name="preheat.tempC" placeholder="°F" /></label>
<label class="field">Preheat time (min)<input class="field-input" name="preheat.minutes" /></label>
<label class="field">Preheat fan (%)<input class="field-input" name="preheat.fanPct" /></label>
<label class="field">Drum (%)<input class="field-input" name="preheat.drumPct" /></label>
</div>
<label class="field-label">Development target (m:ss, optional override)<input class="field-input" name="4.4" placeholder="e.g. 1:15" /></label>
<div class="milestone-list">
<div
class="milestone-row band-row"
@@ -1220,15 +1224,15 @@
name="6.4"
placeholder="0"
aria-label="Batch-size correction"
title="225 g lands first crack ~1:30–2:00 later than 200 g"
title="Optional adjustment from your own observations"
/>
</div>
<div class="ledger-row ledger-subtotal">
<span class="l-fid"></span
><span class="l-label">Subtotal (before pace)</span
><span class="l-label">Timing subtotal</span
><output class="l-val" data-out="subtotalA">—</output>
</div>
<div class="ledger-row ledger-pace">
<div class="ledger-row ledger-pace" hidden>
<span class="l-fid"></span
><span class="l-label">× Machine pace</span
><output class="l-val" data-out="pace-factor">×1.00</output>
@@ -1846,7 +1850,7 @@
</tr>
<tr class="row">
<td class="ln"></td>
<td class="desc">Subtotal (before pace)</td>
<td class="desc">Timing subtotal</td>
<td class="val"><output data-out="subtotalA">—</output></td>
</tr>
<tr class="row">
+3 -1
View File
@@ -77,7 +77,9 @@ export function initAlogPanel({
Object.assign(document.createElement("li"), { textContent: "Loading…" }),
);
try {
const body = await (await fetch("/api/alog/library")).json();
const response = await fetch("/api/alog/library");
if (response.status === 403) throw new Error("The server roast library is available to administrators. Use the file picker for your own logs.");
const body = await response.json();
if (!body.ok || !body.files.length) {
libraryList.replaceChildren(
Object.assign(document.createElement("li"), {
+41 -332
View File
@@ -16,7 +16,7 @@ import {
CHARGE_SETTINGS,
YELLOW_RATIO,
findCultivar,
} from "/shared/reference-data.js";
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
// ---- small DOM builders --------------------------------------------------
@@ -754,60 +754,13 @@ export const HELP_TOPICS = {
const label = input?.closest(".ledger-row")?.querySelector(".l-label");
return label ? [label] : [];
},
render(_plan, unit, profile) {
const pace = profile?.pace;
const paceText =
pace?.source === "learned"
? `Your own account is currently running at ${pace.value.toFixed(2)}× the reference ` +
`pace (learned from ${pace.n} of your logged roasts) — that factor scales your ` +
"whole first-crack line by how fast your roasts have actually run overall. It's a " +
"single blanket ratio across your history, not something that adjusts itself by " +
"batch weight, so this table's specific charge/heat/fan settings are still worth " +
"reading as the reference operator's own notes, not something the learned pace " +
"stands in for."
: "This app now applies a learned per-user pace factor to first crack instead of a " +
"flat added-seconds guess (below, it defaults to 1× until you've logged at least " +
"two roasts with an actual first-crack time). That factor tracks how fast your " +
"machine runs overall — it isn't conditioned on batch weight — so the specific " +
"settings in this table are still the reference operator's own charge/heat/fan " +
"choices, not derived from your machine.";
return frag(
p(
"Batch size was the strongest correlate with first-crack time in the reference " +
"operator's own logged data (r = +0.67): a 226 g batch landed first crack roughly " +
"1:30–2:00 later than 200 g at similar settings — a ~13% weight increase stretching " +
"first crack ~40%, a scaling relationship a flat added-seconds guess can't fully " +
"represent. Field 6.4 below is still that same flat additive guess — you still " +
"enter it by hand for your own batch, and it's now folded into the line that the " +
"learned pace factor scales, so it moves by that factor too. What's new is a " +
"separate learned pace factor (below) that scales your entire first-crack line by " +
"how fast your account's roasts have actually run — but it's one blanket ratio " +
"across your whole history, not a per-batch-weight adjustment, so it doesn't replace " +
"6.4 for batch-to-batch changes.",
),
p(paceText),
buildTable({
cols: [
{ label: "Batch", key: "batch" },
{ label: "Charge probe", num: true, render: (row) => chargeProbeCell(row, unit) },
{ 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.",
}),
);
},
},
render() {
return frag(p("Batch size can change roast behavior, but the app never applies machine pace or batch corrections automatically. Field 6.4 is an optional time adjustment you enter from your own observations. Change one variable at a time and compare the cup."));
},
},
"milestones-ror": {
title: "Milestones & the RoR budget — proven numbers, learned or reference",
title: "Milestones & rate of rise — planning references",
targets: [],
place() {
const subheads = [...document.querySelectorAll("#sec-machine .subhead")];
@@ -816,107 +769,15 @@ export const HELP_TOPICS = {
console.warn('field-help: "milestones-ror" fell back to positional lookup — subhead text changed?');
return subheads[0] ? [subheads[0]] : [];
},
render(_plan, unit, profile) {
const t = (c) => convTemp(c, unit);
const d = (c) => convDelta(c, unit);
const u = unitLabel(unit);
const ru = rateUnitLabel(unit);
const milestoneDefs = [
{ label: "Charge", ref: MACHINE.charge, learnedKey: "charge", timeSuffix: "", note: "The probe reading when beans go in, i.e. your preheat. Higher for larger batches." },
{ label: "Turning point", ref: MACHINE.turningPoint, learnedKey: "tp", timeSuffix: ` @ ${MACHINE.turningPoint.medianTime}`, rangeTimeSuffix: ` @ ${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." },
{ label: "Yellow", ref: MACHINE.yellow, learnedKey: "yellow", timeSuffix: "", note: "Threshold-derived, not observed. Use as a rough guide and start calling it by eye — grassy smell turns to bread, colour goes straw." },
{ label: "First crack", ref: MACHINE.firstCrack, learnedKey: "fc", timeSuffix: "", note: "Tight and reliable in the reference log. Usually the single most trustworthy number here." },
{ label: "Drop (light)", ref: MACHINE.drop, learnedKey: "drop", timeSuffix: "", note: `For darker levels, build the ladder yourself: raise drop ${d(2)}–${d(3)} ${u}, weigh, record — don't extrapolate.` },
];
// Each milestone falls back to the reference band independently — a user might have
// enough of their own First crack readings to trust long before they bother recording
// Turning point, so this isn't a single all-or-nothing learned/reference switch.
const rows = milestoneDefs.map(({ label, ref, learnedKey, timeSuffix, rangeTimeSuffix, note }) => {
const learned = profile?.bands?.[learnedKey];
const useLearned = learned?.source === "learned";
const medianC = useLearned ? learned.medianC : ref.medianC;
const rangeC = useLearned ? learned.rangeC : ref.rangeC;
return {
milestone: useLearned ? `${label} (n=${learned.n})` : label,
median: `${t(medianC)} ${u}${timeSuffix}`,
range: `${t(rangeC[0])}–${t(rangeC[1])} ${u}${rangeTimeSuffix ?? timeSuffix}`,
note,
};
});
const anyLearned = profile?.bandsSource === "learned";
const introText = anyLearned
? "The rows marked (n=…) below are learned from your own logged roasts on this " +
"account; the rest still use the reference operator's original Hottop log until " +
"you've recorded enough actual temperatures of your own. The times and ratios in " +
"this app port between similar drum roasters; the temperatures never do — they're " +
"whichever machine and probe actually produced them."
: "These bands are the reference operator's original 14 Hottop KN-8828B-2K+ roasts — " +
"not yours, unless you're them. They'll start being replaced by your own account's " +
"figures automatically once you log actual temperatures at each milestone. The times " +
"and ratios in this app port between similar drum roasters; the temperatures never do.";
return frag(
p(
`${introText} Reference first crack reads ${t(176)}–${t(187)} ${u}, well below the ` +
`~${t(196)} ${u} a fast, well-immersed probe would show; that doesn't make it wrong, ` +
"it makes it that probe's own.",
),
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 ${ru}`, key: "band", num: true },
],
rows: [
{ phase: "TP → Yellow", band: `${d(MACHINE.ror.tpToYellow[0])}–${d(MACHINE.ror.tpToYellow[1])}` },
{ phase: "Yellow → FC", band: `${d(MACHINE.ror.yellowToFc[0])}–${d(MACHINE.ror.yellowToFc[1])}` },
{ phase: "FC → Drop", band: `${d(MACHINE.ror.fcToDrop[0])}–${d(MACHINE.ror.fcToDrop[1])}` },
],
}),
aside({
tag: "Worked example — Typica, washed, light, 200 g",
text:
"Ledger gives yellow 4:15, first crack 7:30, development 1:30, drop 9:00. Turning " +
`point lands about 0:55 at ${t(85)} ${u}.`,
}),
formula(
`TP→Yellow: (${t(158)} − ${t(85)}) ${u} ÷ 3.33 min ≈ ${preciseRate(73, 3.33, unit)} ${ru}`,
`inside ${d(17)}–${d(25)} ✓`,
),
formula(
`Yellow→FC: (${t(183)} − ${t(158)}) ${u} ÷ 3.25 min ≈ ${preciseRate(25, 3.25, unit)} ${ru}`,
`inside ${d(6)}–${d(10)} ✓`,
),
formula(
`FC→Drop: (${t(190)} − ${t(183)}) ${u} ÷ 1.50 min ≈ ${preciseRate(7, 1.5, unit)} ${ru}`,
`inside ${d(3)}–${d(9)} ✓ — plan is reachable. Checks: drying 47%, Maillard 36%, DTR 17% — all in band.`,
),
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"],
]),
);
},
},
render() {
return frag(
p("Blank temperature fields draw an illustrative curve. Your entered readings take precedence. Roaster selection and historical measurements never adjust the plan."),
p("Preheat setpoint, environmental temperature and bean-probe temperature are different measurements. Record preheat separately. Mark yellow by color and aroma, and first crack by sustained audible cracking."),
p("Aim for controlled progression with declining RoR after its recovery peak. The smooth curve is interpolation, not a physical prediction. Phase-average RoR cannot show every brief crash or flick. Inconsistent entered milestones are flagged without rewriting your targets."),
p("Timing, DTR and turning point alone cannot diagnose cup quality. Evaluate color, aroma, the actual trace and tasting results together.")
);
},
},
actuators: {
title: "Heat & fan — the taper, and what actually moves first crack",
@@ -928,161 +789,28 @@ export const HELP_TOPICS = {
console.warn('field-help: "actuators" fell back to positional lookup — subhead text changed?');
return subheads[1] ? [subheads[1]] : [];
},
render(_plan, unit) {
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", num: true, render: (row) => chargeProbeCell(row, unit) },
{ 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 the reference operator's own logs do NOT tell you",
warn: true,
text:
'"More heat before yellow gives an earlier first crack" does not hold in the reference ' +
"operator's 14-roast log — 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 there are batch size " +
"(+0.67) and time to yellow (+0.59) — both descriptive, neither causal, and specific " +
"to that one operator's machine. The taper is a starting shape, not a validated " +
"control law: for a real lever on your own machine, vary only the time of the first " +
"heat cut across three batches of the same coffee and see what actually moves.",
}),
h4("Fan, measured causally"),
p(
`On the reference machine, isolated fan steps with the burner held steady gave roughly ` +
`${MACHINE.fanEffectCPerMinPer10Pct} °C/min of RoR per +10% fan — only 5 clean windows ` +
"across 4 roasts, but the one number in this reference set from a controlled " +
"comparison rather than a correlation. Worth re-measuring on your own machine before " +
"trusting it there.",
),
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 the reference " +
"operator's 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 own logged roasts disagree, believe your logs — this formula is a planning aid " +
"borrowed from someone else's machine, not a law.",
),
);
},
},
render() {
return frag(
p("Enter heat and airflow steps that you can repeat, with the reason for each change. No machine-specific heat/fan percentages or response formula is applied by this planner."),
p("Use your manufacturer's preheat routine and log the setpoint separately from charge BT. Anticipate the machine's response delay and make controlled changes before excessive momentum builds."),
p("Fan changes can affect heat transfer as well as exhaust. Their effect depends on the equipment and airflow path. Do not infer a universal effect from another operator's percentages."),
p("Compare one deliberate change across repeat batches and judge the cup as well as the trace. A correlation in a small roast history is not a validated control law.")
);
},
},
"sanity-checks": {
title: "Sanity checks — what a failure is telling you",
targets: [".checks-card .dock-card-head h2"],
render() {
return frag(
p(
"Two of these are real checks that stand between an arithmetically valid plan and a " +
"roastable one — a fail sends you back to the ledger, never to the machine. The " +
"other two (drying share, Maillard share) are shown for context only; see the aside " +
"below for why.",
),
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 (informational)",
compute: "yellow ÷ drop",
should: `${SANITY_BANDS.drying.lo}–${SANITY_BANDS.drying.hi}%`,
meaning:
"Can't fail independently of DTR (see below) — shown for context. High → " +
"yellow reads late relative to drop; low → yellow reads early.",
},
{
check: "Maillard share (informational)",
compute: "maillard ÷ drop",
should: `${SANITY_BANDS.maillard.lo}–${SANITY_BANDS.maillard.hi}%`,
meaning:
"Same derivation as drying share, so the same caveat applies. Mirrors it: " +
"whichever way drying share leans, this leans the other.",
},
{
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: "Why drying share and Maillard share stopped being pass/fail",
text:
"Yellow is never entered independently in this app — it's always derived as " +
"0.56 × first crack (see Anchor & profile). That means drying% = 0.56 × (A ÷ D) and " +
"Maillard% = 0.44 × (A ÷ D): both are pure algebra on the DTR check, not separate " +
"measurements. Whenever DTR passes, the arithmetic guarantees drying lands in " +
"44.8–49.3% and Maillard in 35.2–38.7% — comfortably inside both bands, every time. " +
"They used to display green or red checkmarks that implied four independent " +
"validations when only two ever could fail. They're kept here because the " +
"percentages themselves are still useful context — just not a verdict.",
}),
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: "The reference operator's own history runs outside these bands — read before dismissing",
warn: true,
text:
"Across the reference operator's 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 there: Artisan computes the dry end from " +
"a temperature threshold, not an observed colour change, which inflates drying and " +
"deflates Maillard in exactly this pattern — calling yellow by eye and smell is the " +
"fix, not concluding the roasts were structurally wrong. DTR is not an artifact: both " +
"first crack and drop are marked events. A median development of 1:00 is defensible " +
"for the naturals that dominate that log, but 0:45–0:52 is below the floor for any " +
"roast level; if those particular batches cupped sharp or thin, that's the number " +
"that should have moved.",
}),
);
},
},
render() {
return frag(
p("These checks describe a planning range, not a verdict on roast quality. DTR is development divided by total time; a ratio inside 12–20% does not guarantee good coffee, and one outside it does not prove a failure."),
p("Drying and Maillard shares are informational because the planner derives yellow as 0.56 × first crack. They are not independent measurements."),
p("For light plans, modifier suggestions below 1:15 are raised to 1:15 with a warning. This is a starting heuristic. Enter a direct development target to override it; your choice remains visible even outside the planning range."),
p("Check color, aroma, actual RoR, and tasting results together. Longer development may suit a different roast intent; the 4:30 check is a worksheet review threshold, not a universal physical limit.")
);
},
},
"roast-log": {
title: "At the machine — before charging, and during the roast",
@@ -1119,31 +847,12 @@ export const HELP_TOPICS = {
curve: {
title: "Reading the curve — what good looks like",
targets: [".curve-card .dock-card-head h2"],
render() {
return frag(
p(
"Plot the five milestones — bean temperature against the left axis, rate of rise " +
"against the right. Draw the plan before the roast, overlay what actually happened " +
"afterward, so one chart carries both.",
),
aside({
tag: "What a good curve looks like",
text:
"The bean-temperature line falls to the turning point in the first minute, climbs " +
"steeply out of it, then bends progressively flatter all the way to the drop. There " +
"is no straight segment and no second steepening. If your drawn line needs a kink to " +
"connect two milestones, the milestones are wrong — not the coffee.",
}),
aside({
tag: "The rate-of-rise line",
text:
"Starts near its peak just after the turning point (17–25 °C/min on a similar drum roaster) and " +
"declines steadily to somewhere between 3 and 9 °C/min at the drop. It never touches " +
"zero and never turns back up. Watch the region just after first crack in particular: " +
"the exotherm gives you free heat for a few seconds and then withdraws it, and that " +
"hand-off is where a crash appears.",
}),
);
},
},
render() {
return frag(
p("The graph labels milestone times and Fahrenheit temperatures directly, with drying, Maillard and development durations. An attached reference trace appears dashed on the live chart."),
p("The smooth plan line interpolates your milestones; it is not a predicted sensor trace. Blank temperatures are marked illustrative. Enter actual probe references when you have them."),
p("Aim for controlled progression and declining RoR after the recovery peak. Phase-average RoR alone cannot rule out a brief crash or flick. The graph plots BT, not a separate measured RoR trace.")
);
},
},
};
+1 -1
View File
@@ -2,7 +2,7 @@
// 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";
import { HELP_TOPICS } from "./field-help-content.js?v=__ASSET_VERSION__";
export function initFieldHelp({ getPlan, getTempUnit, getMachineProfile }) {
const dialog = document.getElementById("field-help-dialog");
+15 -81
View File
@@ -11,9 +11,10 @@ import {
findProcess,
findRoastLevel,
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY, curveWarnings } 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__";
import { renderReportCurve } from "/shared/report.js?v=__ASSET_VERSION__";
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
@@ -37,73 +38,10 @@ let deferredInstallPrompt = null;
let lotPicker = null;
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
// Learned per-user machine profile (pace factor + temperature bands) from shared/learn.js — see
// its header comment. Cached to localStorage per user so it's available offline, same pattern as
// the plan draft cache below.
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
let machineProfile = null;
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
// curve's fallback milestone temps so the suggested curve matches this user's machine.
let roasterProfile = null;
async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
// A manual pace tweak on the default machine (Machines page) beats the plan-log
// learned pace in the ledger.
if (Number.isFinite(roasterProfile?.paceFactorOverride))
machineProfile = {
...(machineProfile ?? {}),
pace: {
value: roasterProfile.paceFactorOverride,
source: "override",
roasterName: roasterProfile.roaster?.name ?? null,
},
};
} catch {
/* offline — curve falls back to reference machine temps */
}
}
async function loadMachineProfile(userId) {
const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
try {
const response = await fetch("/api/machine-profile");
if (response.ok) {
machineProfile = (await response.json()).profile;
localStorage.setItem(cacheKey, JSON.stringify(machineProfile));
return;
}
} catch {
/* offline — fall through to the cached profile, if any */
}
try {
const cached = localStorage.getItem(cacheKey);
if (cached) machineProfile = JSON.parse(cached);
} catch {
/* no cached profile available; ledger falls back to pace 1.0 (reference) */
}
}
const machineProfile = null;
function renderMachineProfileNote() {
const el = document.getElementById("machine-profile-note");
if (!el) return;
const pace = machineProfile?.pace;
if (pace?.source === "override") {
el.textContent = `Pace ×${pace.value.toFixed(2)} — your manual tweak${pace.roasterName ? ` for ${pace.roasterName}` : ""} (Machines page).`;
return;
}
if (!pace || pace.source !== "learned") {
el.textContent =
"Using reference timing (one operator's Hottop KN-8828B-2K+) — recalibrates to your own machine as you log roasts with an actual first-crack time.";
return;
}
const paceText =
Math.abs(pace.value - 1) < 0.01
? "running close to reference pace"
: `running ${pace.value > 1 ? `${Math.round((pace.value - 1) * 100)}% slower` : `${Math.round((1 - pace.value) * 100)}% faster`} than reference`;
el.textContent = `Learned from ${pace.n} of your own roasts — ${paceText}.`;
document.getElementById("machine-profile-note").textContent = "Plans use your inputs. Roaster selection and roast history do not change timing or temperature suggestions.";
}
// Temperature fields are always stored in state.plan as canonical °C — this is purely a
@@ -111,7 +49,7 @@ function renderMachineProfileNote() {
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "F" ? "F" : "C";
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "C" ? "C" : "F";
const cToF = (c) => (c * 9) / 5 + 32;
const fToC = (f) => ((f - 32) * 5) / 9;
function isTempField(name) {
@@ -357,6 +295,7 @@ function renderLedger() {
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
ledger.warnings.push(...curveWarnings(buildPlanCurve(state.plan, ledger)));
const warnings = document.getElementById("ledger-warnings");
warnings.replaceChildren(
...ledger.warnings.map((warning) => {
@@ -495,7 +434,7 @@ function paintCurveInto(planGroup, refGroup, planPoints, ref) {
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger, roasterProfile);
const planPoints = buildPlanCurve(state.plan, ledger);
const ref = state.plan.reference;
paintCurveInto(
@@ -504,12 +443,10 @@ function renderCurve(ledger) {
planPoints,
ref,
);
paintCurveInto(
document.getElementById("plan-curve-live"),
document.getElementById("ref-curve-live"),
planPoints,
ref,
);
const live = document.getElementById("curve-svg-live");
live.setAttribute("viewBox", "0 0 900 440");
live.innerHTML = renderReportCurve(planPoints, ledger, ref).replace(/^<svg[^>]*>/, "").replace(/<\/svg>$/, "");
}
// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
@@ -750,7 +687,7 @@ function updateTempUnitUI() {
const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c);
for (const el of document.querySelectorAll("[data-tempc]"))
el.textContent = String(conv(Number(el.dataset.tempc)));
for (const el of document.querySelectorAll("[data-band-lo]")) {
for (const el of document.querySelectorAll("td[data-band-lo], text[data-band-lo]")) {
const lo = conv(Number(el.dataset.bandLo));
const hi = conv(Number(el.dataset.bandHi));
el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}–${hi} ${label}${el.dataset.bandSuffix ?? ""}`;
@@ -1177,14 +1114,12 @@ async function init() {
applyAvatar(document.getElementById("nav-user-avatar"), user);
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
document.getElementById("alog-library-refresh").hidden = user.role !== "admin";
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
// Roaster profile loads after the machine profile on purpose: its pace override (if
// any) must be applied on top of the freshly loaded machine profile, not raced with it.
await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
await loadRoasterProfile();
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
@@ -1213,7 +1148,6 @@ async function init() {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
if (offlineUserId && csrfToken()) {
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
await loadMachineProfile(offlineUserId);
}
} catch {
/* no local draft is available */
@@ -1251,7 +1185,7 @@ async function init() {
getRemotePlanId: () => remotePlanId,
flushCurrentPlan,
});
initPrint({ beforePrint: renderFormFromPlan });
initPrint({ getPlan: () => state.plan, getName: () => currentPlanName });
lotPicker = initLotPicker({
state,
recompute,
+63 -29
View File
@@ -1,32 +1,66 @@
// Bakes live input values into their sibling .pv span before printing (so text doesn't clip
// at a fixed .f width the way a raw <input> would), then clears them after. One DOM, one
// rendering — see app.css's @media print block for the corresponding display swap.
//
// Operates on #print-sheet (not the passed-in screen `form`): the print worksheet lives
// outside #plan-form on purpose, so its inputs are addressed by container id instead.
export function initPrint({ beforePrint }) {
document.getElementById("btn-print").addEventListener("click", () => window.print());
import {
renderReportBody,
renderReportHtml,
REPORT_CSS,
} from "/shared/report.js?v=__ASSET_VERSION__";
export function initPrint({ getPlan, getName }) {
const style = document.createElement("style");
style.textContent =
REPORT_CSS +
"\n#report-print{display:none}@media print{body>*:not(#report-print){display:none!important}#report-print{display:block!important}}";
document.head.append(style);
const mount = document.createElement("div");
mount.id = "report-print";
document.body.append(mount);
window.addEventListener("beforeprint", () => {
beforePrint?.(); // re-sync the print worksheet's inputs from state.plan before baking
bakeValues();
mount.innerHTML = renderReportBody(getPlan(), getName());
});
window.addEventListener("afterprint", () => mount.replaceChildren());
const print = document.getElementById("btn-print");
print.addEventListener("click", () => window.print());
const download = document.createElement("button");
download.type = "button";
download.className = "ghost-btn";
download.textContent = "Download HTML";
download.addEventListener("click", () => {
const url = URL.createObjectURL(
new Blob([renderReportHtml(getPlan(), getName())], {
type: "text/html;charset=utf-8",
}),
);
const a = document.createElement("a");
a.href = url;
a.download =
(getName() || getPlan().fields?.["0.1"] || "roast-plan").replace(
/[^a-z0-9_-]+/gi,
"-",
) + ".html";
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
print.after(download);
const preview = document.createElement("button");
preview.type = "button";
preview.className = "ghost-btn";
preview.textContent = "Preview report";
download.after(preview);
const dialog = document.createElement("dialog");
dialog.style.cssText =
"width: min(1000px,96vw);height:90vh;padding:12px;border:1px solid #ccc;border-radius:12px";
const close = document.createElement("button");
close.type = "button";
close.textContent = "Close preview";
close.addEventListener("click", () => dialog.close());
const frame = document.createElement("iframe");
frame.title = "Printable roast report";
frame.setAttribute("sandbox", "");
frame.style.cssText = "width:100%;height:calc(100% - 40px);border:0";
dialog.append(close, frame);
document.body.append(dialog);
preview.addEventListener("click", () => {
frame.srcdoc = renderReportHtml(getPlan(), getName());
dialog.showModal();
});
window.addEventListener("afterprint", () => clearBaked());
}
function printSheet() {
return document.getElementById("print-sheet");
}
function bakeValues() {
for (const el of printSheet().querySelectorAll("input.ws-input, textarea.ws-input")) {
if (el.type === "radio" || el.type === "checkbox") continue;
const pv = el.nextElementSibling;
if (pv && pv.classList.contains("pv")) pv.textContent = el.value || "";
}
}
function clearBaked() {
for (const pv of printSheet().querySelectorAll(".pv")) pv.textContent = "";
}
+11 -82
View File
@@ -11,16 +11,14 @@ const fmtTime = (s) =>
? "—"
: `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`;
// Metric rows of the report: what the planner actually consumes from a machine's profile.
// `fmt` renders read-only values; overrides are edited in the raw unit (°C / seconds / ×).
// Descriptive history only; machine statistics never modify plans.
const METRICS = [
{ key: "chargeTempC", label: "Charge temp (curve fallback)", unit: "°C" },
{ key: "turningPointS", label: "Turning point time (thermal lag)", unit: "s", fmt: fmtTime },
{ key: "turningPointTempC", label: "Turning point temp", unit: "°C" },
{ key: "yellowTempC", label: "Yellow temp (curve fallback)", unit: "°C" },
{ key: "firstCrackTempC", label: "First crack temp (curve fallback)", unit: "°C" },
{ key: "dropTempC", label: "Drop temp (curve fallback)", unit: "°C" },
{ key: "paceFactor", label: "Pace factor (multiplies first-crack timing)", unit: "×" },
{ key: "chargeTempC", label: "Charge BT", unit: "°F" },
{ key: "turningPointS", label: "Turning point time", unit: "", fmt: fmtTime },
{ key: "turningPointTempC", label: "Turning point BT", unit: "°F" },
{ key: "yellowTempC", label: "Yellow BT", unit: "°F" },
{ key: "firstCrackTempC", label: "First crack BT", unit: "°F" },
{ key: "dropTempC", label: "Drop BT", unit: "°F" },
];
function renderRoasters() {
@@ -135,10 +133,6 @@ function renderRoasters() {
);
}
function overrideInputs() {
return [...document.querySelectorAll("#report-rows input[data-key]")];
}
function renderReport() {
const card = document.getElementById("report-card");
if (!selectedId || !profile) {
@@ -168,32 +162,9 @@ function renderReport() {
? metric.key === "paceFactor"
? "learned from plan logs"
: "—"
: `${metric.fmt ? metric.fmt(learnedValue) : learnedValue} ${metric.fmt ? "" : metric.unit}`.trim();
: `${metric.fmt ? metric.fmt(learnedValue) : Math.round(learnedValue * 1.8 + 32)} ${metric.fmt ? "" : metric.unit}`.trim();
const overrideCell = document.createElement("td");
const input = document.createElement("input");
input.className = "field-input sm";
input.type = "number";
input.step = "any";
input.style.maxWidth = "110px";
input.dataset.key = metric.key;
input.placeholder = metric.unit;
const overrideValue = profile.overrides?.[metric.key];
if (overrideValue != null) input.value = overrideValue;
overrideCell.append(input);
const appliedCell = document.createElement("td");
const applied =
overrideValue ??
(metric.key === "paceFactor" ? null : profile.learnedMedians?.[metric.key]);
appliedCell.textContent =
applied == null
? metric.key === "paceFactor"
? "plan-log pace"
: "reference default"
: `${metric.fmt ? metric.fmt(applied) : applied} ${metric.fmt ? "" : metric.unit}${overrideValue != null ? " (your tweak)" : " (learned)"}`.trim();
tr.append(label, learnedCell, overrideCell, appliedCell);
tr.append(label, learnedCell);
return tr;
}),
);
@@ -209,23 +180,7 @@ function renderReport() {
const list = document.createElement("ul");
list.style.paddingLeft = "18px";
list.style.fontSize = "13px";
const items = [];
if (profile.n > 0)
items.push(
"Blank milestone temps on the planner's curve use this machine's learned medians instead of the reference machine.",
`New plans assume the turning point around ${fmtTime(profile.medians?.turningPointS)} — this machine's measured thermal lag.`,
);
if (profile.paceFactorOverride != null)
items.push(
`First-crack timing is multiplied by your pace tweak ×${profile.paceFactorOverride} instead of the plan-log learned pace.`,
);
items.push(
"Roast reviews compare each new roast against this machine's own typical behavior, not a generic one.",
);
if (!(roaster?.isDefault ?? false))
items.push(
"Note: the planner uses the DEFAULT machine's profile — make this one default to plan with it.",
);
const items = ["Historical measurements only. Selecting a default roaster organizes uploads; it does not change roast plans."];
for (const text of items) {
const li = document.createElement("li");
li.textContent = text;
@@ -246,31 +201,7 @@ async function selectRoaster(id) {
renderReport();
}
async function saveOverrides(clear = false) {
if (!selectedId) return;
const overrides = {};
if (!clear)
for (const input of overrideInputs()) {
if (input.value === "") continue;
const value = Number(input.value);
if (!Number.isFinite(value)) {
showToast(`"${input.value}" is not a number.`, "fail");
return;
}
overrides[input.dataset.key] = value;
}
try {
await api(`/api/roasters/${selectedId}`, {
method: "PUT",
body: JSON.stringify({ overrides }),
});
showToast(clear ? "Tweaks cleared — using learned values." : "Tweaks saved.");
await loadRoasters();
await selectRoaster(selectedId);
} catch (error) {
showToast(error.message, "fail");
}
}
async function loadRoasters() {
try {
@@ -298,8 +229,6 @@ document.getElementById("roaster-form").addEventListener("submit", async (event)
showToast(error.message, "fail");
}
});
document.getElementById("save-overrides").addEventListener("click", () => saveOverrides(false));
document.getElementById("clear-overrides").addEventListener("click", () => saveOverrides(true));
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
+3 -11
View File
@@ -53,8 +53,7 @@
<div class="brand-text">
<h1>Machines</h1>
<p class="brand-sub">
Your roasters — what the app has learned from each, and your
tweaks to it
Your roasters and recorded history. Machines do not change roast plans.
</p>
</div>
</div>
@@ -122,10 +121,9 @@
<table class="data-table" id="report-table">
<thead>
<tr>
<th>What the plans use</th>
<th>Recorded milestone</th>
<th>Learned from your roasts</th>
<th>Your tweak</th>
<th>Applied</th>
</tr>
</thead>
<tbody id="report-rows"></tbody>
@@ -133,12 +131,6 @@
</div>
<p class="field-note" id="report-ror"></p>
<div id="report-adjustments" style="margin-top:8px"></div>
<button class="primary-btn" type="button" id="save-overrides" style="margin-top:10px">
Save tweaks
</button>
<button class="ghost-btn" type="button" id="clear-overrides" style="margin-top:10px">
Clear all tweaks (use learned values)
</button>
</div>
</div>
</section>
+1
View File
@@ -29,6 +29,7 @@ const APP_SHELL = [
"/shared/time.js?v=__ASSET_VERSION__",
"/shared/reference-data.js?v=__ASSET_VERSION__",
"/shared/curve.js?v=__ASSET_VERSION__",
"/shared/report.js?v=__ASSET_VERSION__",
"/shared/cupping.js?v=__ASSET_VERSION__",
];