@@ -1270,6 +1281,16 @@
title="225 g lands first crack ~1:30–2:00 later than 200 g"
/>
+
+ Subtotal (before pace)
+
+
+ × Machine pace
+
First crack
@@ -1325,19 +1346,22 @@
Drying share
- target 43–51%
+ informational, target 43–51%
high = late yellow, browning starved · low = rushed
- before internal pressure builtDerived from the development ratio (yellow is 0.56 ×
+ first crack, not entered independently) — this can't fail
+ on its own. High = late yellow, browning starved · low =
+ rushed before internal pressure built.
Maillard share
- target 33–39%
+ informational, target 33–39%
low = too small a browning window for sweetness and
- complexitySame derivation as drying share — informational, not an
+ independent check. Low = too small a browning window for
+ sweetness and complexity.
@@ -1878,6 +1902,22 @@
/>
+
+ |
+ Subtotal (before pace) |
+ |
+
+
+ |
+
+ × Machine pace
+ learned from your own roasts, else ×1.00
+ (reference)
+ |
+ |
+
| A |
FIRST CRACK |
@@ -1964,16 +2004,16 @@
- | Drying share |
+ Drying share (informational) |
43–51 % |
|
|
- high = late yellow · low = rushed |
+ can't fail on its own — see * below |
- | Maillard share |
+ Maillard share (informational) |
33–39 % |
@@ -1982,7 +2022,7 @@
class="num passcell unknown"
data-pass="maillard"
> |
- low = too little browning |
+ can't fail on its own — see * below |
| Development ratio |
@@ -2004,6 +2044,13 @@
+
diff --git a/public/js/field-help-content.js b/public/js/field-help-content.js
index bf788bf..4ef7c18 100644
--- a/public/js/field-help-content.js
+++ b/public/js/field-help-content.js
@@ -755,15 +755,38 @@ export const HELP_TOPICS = {
const label = input?.closest(".ledger-row")?.querySelector(".l-label");
return label ? [label] : [];
},
- render(_plan, unit) {
+ 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 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.",
+ "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" },
@@ -785,7 +808,7 @@ export const HELP_TOPICS = {
},
"milestones-ror": {
- title: "Milestones & the RoR budget — your machine's proven numbers",
+ title: "Milestones & the RoR budget — proven numbers, learned or reference",
targets: [],
place() {
const subheads = [...document.querySelectorAll("#sec-machine .subhead")];
@@ -794,24 +817,49 @@ 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) {
+ 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 rows = [
- { milestone: "Charge", median: `${t(MACHINE.charge.medianC)} ${u}`, range: `${t(MACHINE.charge.rangeC[0])}–${t(MACHINE.charge.rangeC[1])} ${u}`, note: "The probe reading when beans go in, i.e. your preheat. Higher for larger batches." },
- { milestone: "Turning point", median: `${t(MACHINE.turningPoint.medianC)} ${u} @ ${MACHINE.turningPoint.medianTime}`, range: `${t(MACHINE.turningPoint.rangeC[0])}–${t(MACHINE.turningPoint.rangeC[1])} ${u} @ ${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: `${t(MACHINE.yellow.medianC)} ${u}`, range: `${t(MACHINE.yellow.rangeC[0])}–${t(MACHINE.yellow.rangeC[1])} ${u}`, 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: `${t(MACHINE.firstCrack.medianC)} ${u}`, range: `${t(MACHINE.firstCrack.rangeC[0])}–${t(MACHINE.firstCrack.rangeC[1])} ${u}`, note: "Tight and reliable across all your lots. This is your most trustworthy single number." },
- { milestone: "Drop (light)", median: `${t(MACHINE.drop.medianC)} ${u}`, range: `${t(MACHINE.drop.rangeC[0])}–${t(MACHINE.drop.rangeC[1])} ${u}`, note: `All your logged roasts are light. For darker levels, build the ladder yourself: raise drop ${d(2)}–${d(3)} ${u}, weigh, record — don't extrapolate.` },
+ 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(
- "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 ${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 yours.",
+ `${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: [
@@ -912,22 +960,25 @@ export const HELP_TOPICS = {
"change now shows up about a minute from now.",
}),
aside({
- tag: "What your logs will NOT tell you",
+ 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 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.",
+ '"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(
- `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.",
+ `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)"),
@@ -935,10 +986,11 @@ export const HELP_TOPICS = {
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.",
+ "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.",
),
);
},
@@ -950,8 +1002,10 @@ export const HELP_TOPICS = {
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.",
+ "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: [
@@ -962,21 +1016,20 @@ export const HELP_TOPICS = {
],
rows: [
{
- check: "Drying share",
+ check: "Drying share (informational)",
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.",
+ "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",
+ check: "Maillard share (informational)",
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.",
+ "Same derivation as drying share, so the same caveat applies. Mirrors it: " +
+ "whichever way drying share leans, this leans the other.",
},
{
check: "Development ratio",
@@ -994,6 +1047,18 @@ export const HELP_TOPICS = {
},
],
}),
+ 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:
@@ -1002,18 +1067,19 @@ export const HELP_TOPICS = {
"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",
+ tag: "The reference operator's 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.",
+ "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.",
}),
);
},
@@ -1072,7 +1138,7 @@ export const HELP_TOPICS = {
aside({
tag: "The rate-of-rise line",
text:
- "Starts near its peak just after the turning point (17–25 °C/min on your machine) and " +
+ "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 " +
diff --git a/public/js/field-help.js b/public/js/field-help.js
index 9346ae1..462dd50 100644
--- a/public/js/field-help.js
+++ b/public/js/field-help.js
@@ -4,7 +4,7 @@
import { HELP_TOPICS } from "./field-help-content.js";
-export function initFieldHelp({ getPlan, getTempUnit }) {
+export function initFieldHelp({ getPlan, getTempUnit, getMachineProfile }) {
const dialog = document.getElementById("field-help-dialog");
if (!dialog) return;
const titleEl = document.getElementById("help-dialog-title");
@@ -16,7 +16,7 @@ export function initFieldHelp({ getPlan, getTempUnit }) {
if (!topic) return;
lastTrigger = trigger;
titleEl.textContent = topic.title;
- bodyEl.replaceChildren(topic.render(getPlan(), getTempUnit?.()));
+ bodyEl.replaceChildren(topic.render(getPlan(), getTempUnit?.(), getMachineProfile?.()));
bodyEl.scrollTop = 0;
dialog.showModal();
}
diff --git a/public/js/lot-picker.js b/public/js/lot-picker.js
index d66678c..18fface 100644
--- a/public/js/lot-picker.js
+++ b/public/js/lot-picker.js
@@ -7,15 +7,35 @@ function findLot(id) {
return lots.find((l) => l.id === id);
}
+// Matches the phrasing the app's own symptom-fix reference table uses ("First crack +0:30",
+// "First crack −0:30") — only a note in exactly that shape is safe to offer as a one-click ± Refine
+// value. Free text like "Re-weight the blend" or "Smaller batch" isn't a first-crack correction at
+// all, so it's shown for context but never auto-applied. The sign class includes the U+2212 minus
+// sign the app's own SYMPTOM_FIXES table is written with (see shared/reference-data.js) — an
+// ASCII-only class would silently refuse to recognize every slow-down correction it suggests.
+const FC_REFINE_RE = /first crack\s*([+‒–—−-]\d{1,2}:\d{2})/i;
+// The captured sign may be a non-ASCII dash; normalize to ASCII "-" before it's stored into
+// field 1.6, matching the canonical form parseDuration/formatDuration use everywhere else.
+const normalizeSign = (value) => value.replace(/^[+‒–—−]/, (c) => (c === "+" ? "+" : "-"));
+
/** Wires the "From inventory lot" picker (in The Coffee) and the "Draw from lot" action
* (in Roast Log — Plan vs. Actual). `getRemotePlanId` is a function since the planner's
* remotePlanId is module-local and can change after a sync. */
-export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId }) {
+export function initLotPicker({
+ state,
+ recompute,
+ flushCurrentPlan,
+ getRemotePlanId,
+ renderFormFromPlan,
+}) {
const select = document.getElementById("lot-select");
const note = document.getElementById("lot-picker-note");
const consumeRow = document.getElementById("inventory-consume");
const consumeLabel = document.getElementById("inventory-consume-label");
const consumeBtn = document.getElementById("btn-consume");
+ const refineBox = document.getElementById("refine-suggestion");
+ const refineText = document.getElementById("refine-suggestion-text");
+ const refineApplyBtn = document.getElementById("btn-apply-refine");
function renderOptions() {
const current = state.plan.inventory.lotId;
@@ -53,6 +73,7 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
}
renderOptions();
updateConsumeRow();
+ loadLastRefine(state.plan.inventory.lotId);
}
function updateConsumeRow() {
@@ -83,6 +104,46 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
}
}
+ // The auto-refine-carry-forward feature: closes the loop between cupping's "One change next
+ // batch" and this plan's ± Refine (1.6) instead of leaving the user to retype their own past
+ // conclusion. Never applied silently — only offered, and only when it can be parsed as an
+ // unambiguous first-crack correction (see FC_REFINE_RE above).
+ async function loadLastRefine(lotId) {
+ if (!lotId) {
+ refineBox.classList.add("hidden");
+ return;
+ }
+ let refine = null;
+ try {
+ refine = (await api(`/api/inventory/${lotId}/last-refine`)).refine;
+ } catch {
+ refine = null;
+ }
+ if (!refine?.oneChange) {
+ refineBox.classList.add("hidden");
+ return;
+ }
+ refineBox.classList.remove("hidden");
+ refineText.textContent = `Last time on this lot: "${refine.oneChange}"`;
+ const match = refine.oneChange.match(FC_REFINE_RE);
+ const value = match ? normalizeSign(match[1]) : null;
+ const currentRefine = (state.plan.fields["1.6"] || "").trim();
+ const isDefault = currentRefine === "" || currentRefine === "0";
+ if (value && isDefault) {
+ refineApplyBtn.classList.remove("hidden");
+ refineApplyBtn.textContent = `Apply ${value} to ± Refine`;
+ refineApplyBtn.onclick = () => {
+ state.plan.fields["1.6"] = value;
+ renderFormFromPlan?.();
+ recompute();
+ refineApplyBtn.classList.add("hidden");
+ showToast(`± Refine set to ${value}.`);
+ };
+ } else {
+ refineApplyBtn.classList.add("hidden");
+ }
+ }
+
select.addEventListener("change", () => {
const lotId = select.value;
state.plan.inventory.lotId = lotId;
@@ -102,6 +163,7 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
"Optional — link a lot and the Roast Log can draw the green weight down when you charge.";
}
updateConsumeRow();
+ loadLastRefine(lotId);
recompute();
});
@@ -147,5 +209,9 @@ export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePla
});
loadLots();
- return { updateConsumeRow, renderOptions };
+ return {
+ updateConsumeRow,
+ renderOptions,
+ refreshRefineSuggestion: () => loadLastRefine(state.plan.inventory.lotId),
+ };
}
diff --git a/public/js/main.js b/public/js/main.js
index 3518ebf..dd532b9 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -32,6 +32,48 @@ 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;
+
+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) */
+ }
+}
+
+function renderMachineProfileNote() {
+ const el = document.getElementById("machine-profile-note");
+ if (!el) return;
+ const pace = machineProfile?.pace;
+ 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}.`;
+}
+
// Temperature fields are always stored in state.plan as canonical °C — this is purely a
// display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
@@ -259,13 +301,15 @@ function fmtOut(id, text) {
}
function renderLedger() {
- const ledger = computeLedger(state.plan);
+ const ledger = computeLedger(state.plan, machineProfile);
const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));
fmtOut("l1", d(ledger.lines.l1));
fmtOut("l2", ds(ledger.lines.l2));
fmtOut("l3", ds(ledger.lines.l3));
+ fmtOut("subtotalA", d(ledger.subtotalA));
+ fmtOut("pace-factor", `×${ledger.pace.toFixed(2)}`);
fmtOut("A", d(ledger.A));
fmtOut("yellow", d(ledger.yellow));
fmtOut("maillard", d(ledger.maillard));
@@ -293,10 +337,14 @@ function renderLedger() {
for (const [key, check] of Object.entries(ledger.checks)) {
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
- cell.classList.remove("pass", "fail", "unknown");
- cell.classList.add(
- check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
- );
+ cell.classList.remove("pass", "fail", "unknown", "informational");
+ // Drying/Maillard are mathematically derived from DTR (yellow = 0.56 × A), so they can
+ // never fail independently of it — shown as plain figures, not a pass/fail verdict.
+ if (check.informational) cell.classList.add("informational");
+ else
+ cell.classList.add(
+ check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
+ );
}
}
@@ -556,14 +604,16 @@ function loadFromStorage(userId) {
try {
storageKey = `${STORAGE_PREFIX}:${userId}`;
localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
- // A shared browser must never retain a previous account's local-only draft.
+ const machineProfileKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
+ // A shared browser must never retain a previous account's local-only draft or cache.
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (
(key?.startsWith(`${STORAGE_PREFIX}:`) &&
key !== storageKey &&
key !== `${STORAGE_PREFIX}:last-user`) ||
- key === "roastPlannerPlan.v1"
+ key === "roastPlannerPlan.v1" ||
+ (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`) && key !== machineProfileKey)
)
localStorage.removeItem(key);
}
@@ -585,6 +635,10 @@ function clearDraft() {
if (storageKey) localStorage.removeItem(storageKey);
try {
localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
+ for (let i = localStorage.length - 1; i >= 0; i--) {
+ const key = localStorage.key(i);
+ if (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`)) localStorage.removeItem(key);
+ }
} catch {
/* unavailable storage */
}
@@ -803,6 +857,7 @@ async function selectPlan(plan) {
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
// leave the dropdown showing the previous plan's selection or a blank state.
lotPicker?.renderOptions();
+ lotPicker?.refreshRefineSuggestion();
document.querySelector("[data-close-drawer]")?.click();
}
async function newPlan() {
@@ -818,6 +873,7 @@ async function newPlan() {
renderFormFromPlan();
recompute();
lotPicker?.renderOptions();
+ lotPicker?.refreshRefineSuggestion();
}
function wireToolbar() {
@@ -997,7 +1053,7 @@ async function init() {
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
- await loadPlans();
+ await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
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
@@ -1024,8 +1080,10 @@ async function init() {
// last account only until logout, allowing that account's local draft to reopen offline.
try {
const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
- if (offlineUserId && csrfToken())
+ if (offlineUserId && csrfToken()) {
state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
+ await loadMachineProfile(offlineUserId);
+ }
} catch {
/* no local draft is available */
}
@@ -1034,7 +1092,11 @@ async function init() {
renderActuators();
wireCultivarDatalist();
wireWhyPanels();
- initFieldHelp({ getPlan: () => state.plan, getTempUnit: () => tempUnit });
+ initFieldHelp({
+ getPlan: () => state.plan,
+ getTempUnit: () => tempUnit,
+ getMachineProfile: () => machineProfile,
+ });
renderBandRanges();
renderFormFromPlan();
wireForm();
@@ -1059,8 +1121,10 @@ async function init() {
recompute,
flushCurrentPlan,
getRemotePlanId: () => remotePlanId,
+ renderFormFromPlan,
});
wireCuppingLink();
+ renderMachineProfileNote();
recompute();
}
diff --git a/public/worksheet.css b/public/worksheet.css
index 6f6d12b..2b4b4a0 100644
--- a/public/worksheet.css
+++ b/public/worksheet.css
@@ -77,6 +77,7 @@ tbody tr:nth-child(even) td{ background:#faf7f4; }
td.num,th.num{ text-align:center; font-variant-numeric:tabular-nums; white-space:nowrap; }
.mono{ font-family:"SF Mono",Menlo,Consolas,monospace; font-size:7.6pt; }
.tight td{ padding:.85mm 1.15mm; }
+.wsfoot{ font-size:6.8pt; color:var(--mid); line-height:1.35; margin:.8mm 0 0; }
/* ─── Callouts ────────────────────────────────────────────────────────── */
.why{
diff --git a/server/app.js b/server/app.js
index b0bbcac..1a1eb6b 100644
--- a/server/app.js
+++ b/server/app.js
@@ -8,6 +8,7 @@ import { parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { sendMail } from "./mailer.js";
import { coerceSession, computeTotalScore, blankSession } from "../shared/cupping.js";
+import { computeMachineProfile } from "../shared/learn.js";
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
const token = () => crypto.randomBytes(32).toString("base64url");
@@ -723,6 +724,21 @@ export function createApp({ db, root, env = process.env } = {}) {
},
);
+ // Learned per-user machine profile (pace factor + temperature bands) from this account's own
+ // completed roasts — see shared/learn.js. Deliberately reads every one of the user's plans
+ // rather than paginating: a personal roast log tops out at low hundreds of rows, and this is
+ // the only place that number gets reduced, so there's nothing to cache incrementally against.
+ app.get("/api/machine-profile", requireAuth, async (req, res, next) => {
+ try {
+ const rows = (
+ await db.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id])
+ ).rows;
+ res.json({ ok: true, profile: computeMachineProfile(rows.map((r) => r.plan)) });
+ } catch (e) {
+ next(e);
+ }
+ });
+
// ─── Inventory ─────────────────────────────────────────────────────────
// Strict on purpose: JSON.stringify silently turns a client-side NaN (e.g. a non-numeric
// weight/cost typed into a field that isn't really constrained to digits) into null, which
@@ -850,6 +866,46 @@ export function createApp({ db, root, env = process.env } = {}) {
}
},
);
+ // The auto-refine-carry-forward feature: the most recent past roast against this lot whose
+ // "One change next batch" note was actually filled in, so the planner can offer it as a
+ // starting ± Refine value instead of the user retyping their own prior conclusion. Reads
+ // inventory.lotId straight off the plan JSONB — no join needed, and scoping to this user's own
+ // roast_plans is what keeps this ownership-safe regardless of whether :id even belongs to them.
+ app.get(
+ "/api/inventory/:id/last-refine",
+ requireAuth,
+ requireUuidParam("id"),
+ async (req, res, next) => {
+ try {
+ const lot = (
+ await db.query(
+ "SELECT id FROM green_bean_lots WHERE id=$1 AND user_id=$2",
+ [req.params.id, req.user.id],
+ )
+ ).rows[0];
+ if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
+ const row = (
+ await db.query(
+ `SELECT plan->'afterRoast'->>'oneChange' AS one_change,
+ plan->'fields'->>'0.1' AS plan_title, updated_at
+ FROM roast_plans
+ WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
+ AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
+ ORDER BY updated_at DESC LIMIT 1`,
+ [req.user.id, req.params.id],
+ )
+ ).rows[0];
+ res.json({
+ ok: true,
+ refine: row
+ ? { oneChange: row.one_change, planTitle: row.plan_title, atIso: row.updated_at }
+ : null,
+ });
+ } catch (e) {
+ next(e);
+ }
+ },
+ );
app.put(
"/api/inventory/:id",
requireAuth,
diff --git a/shared/learn.js b/shared/learn.js
new file mode 100644
index 0000000..30ba44e
--- /dev/null
+++ b/shared/learn.js
@@ -0,0 +1,132 @@
+// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
+//
+// Learns a per-user machine profile from the account's own completed roasts, per Fable's
+// methodology review (2026-07-31): the reference MACHINE bands and the additive batch-size
+// correction in shared/reference-data.js are one operator's 14 Hottop roasts, frozen into the
+// data layer as though universal. This module recalibrates toward each account's own logged
+// history as it accumulates — a pace factor that replaces the additive batch correction with a
+// multiplicative one (see shared/ledger.js), and temperature bands that supersede the reference
+// MACHINE constants once there's enough data to trust. Below the sample thresholds it explicitly
+// falls back to the reference numbers (pace 1.0, source "reference") rather than overfitting to
+// one or two roasts.
+
+import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
+
+// Below this many valid (predicted, actual) FC pairs, trust the reference pace of 1.0 rather than
+// a ratio computed from too little evidence.
+const MIN_PACE_SAMPLES = 2;
+// A single ratio outside this range is far more likely a typo'd actual-FC time (or a plan filled
+// in for something other than an actual roast) than a real machine running that far from the
+// reference — excluded before the median rather than allowed to define it in a small sample.
+const PLAUSIBLE_RATIO = [0.5, 2];
+// Hard bound on the final learned value regardless of how many samples agreed — no single
+// account's history should be able to plan a roast at less than 60% or more than 160% of the
+// reference timing without a human noticing something is wrong first.
+const PACE_CLAMP = [0.6, 1.6];
+// Below this many recorded actual temperatures for a milestone, keep using the reference band for
+// just that milestone (bands are tracked and thresholded independently, not as a single all-or-
+// nothing switch — a user might log FC reliably long before they bother recording turning point).
+const MIN_BAND_SAMPLES = 3;
+
+const MILESTONES = ["charge", "tp", "yellow", "fc", "drop"];
+
+/** Predicted first-crack seconds from a plan's own fields, mirroring ledger.js's
+ * l1+l2+l3+l4 — the FULL pre-pace prediction, deliberately INCLUDING the manual batch
+ * correction (field 6.4). Excluding l4 here would double-count it: pace would absorb whatever
+ * 6.4 was historically compensating for, and ledger.js would then add that same 6.4 back on top
+ * going forward. Including it means pace is fit as a pure residual — whatever the anchor, refine,
+ * bean-condition AND that historical 6.4 entry together still didn't explain — which is exactly
+ * what ledger.js's `Math.round((baseA + l4) * pace)` expects when applied to a new plan's own l4.
+ * Returns null if the plan has no anchor. */
+function predictedFcSeconds(fields) {
+ if (!fields) return null;
+ const isBlend = fields["2.1"] === "blend";
+ const l1 = parseRangeMidpoint(isBlend ? fields["2.4"] : fields["1.4"]);
+ if (l1 === null) return null;
+ const l2 = parseDuration(fields["1.6"]) ?? 0;
+ const l3 = parseDuration(fields["5.6"]) ?? 0;
+ const l4 = parseDuration(fields["6.4"]) ?? 0;
+ return l1 + l2 + l3 + l4;
+}
+
+function median(nums) {
+ if (nums.length === 0) return null;
+ const sorted = [...nums].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+/**
+ * @param {Array<{fields?: object, planActual?: object}>} plans - the user's own roast_plans.plan
+ * blobs (any shape/order; malformed or in-progress entries are simply skipped, not thrown on).
+ * @returns {{
+ * pace: { value: number, n: number, source: "learned"|"reference" },
+ * bands: Record<"charge"|"tp"|"yellow"|"fc"|"drop",
+ * { medianC: number|null, rangeC: [number,number]|null, n: number, source: "learned"|"reference" }>,
+ * bandsSource: "learned"|"reference",
+ * totalPlans: number,
+ * }}
+ */
+export function computeMachineProfile(plans) {
+ const list = Array.isArray(plans) ? plans : [];
+ const paceRatios = [];
+ const tempsByMilestone = { charge: [], tp: [], yellow: [], fc: [], drop: [] };
+
+ for (const plan of list) {
+ const fields = plan?.fields;
+ const actual = plan?.planActual;
+ if (!actual || typeof actual !== "object") continue;
+
+ const actualFcS = parseDuration(actual.fc?.actualTime);
+ if (actualFcS !== null && actualFcS > 0) {
+ const predicted = predictedFcSeconds(fields);
+ if (predicted !== null && predicted > 0) {
+ const ratio = actualFcS / predicted;
+ // A ratio this far from 1 is far likelier to be a typo than a real reading —
+ // excluded entirely rather than letting one bad entry define (or count toward the
+ // sample size backing) the learned pace.
+ if (ratio >= PLAUSIBLE_RATIO[0] && ratio <= PLAUSIBLE_RATIO[1]) paceRatios.push(ratio);
+ }
+ }
+
+ for (const m of MILESTONES) {
+ const tempC = Number.parseFloat(actual[m]?.actualBt);
+ if (Number.isFinite(tempC)) tempsByMilestone[m].push(tempC);
+ }
+ }
+
+ const paceMedian = paceRatios.length >= MIN_PACE_SAMPLES ? median(paceRatios) : null;
+ const clampedPace =
+ paceMedian === null
+ ? null
+ : Math.min(PACE_CLAMP[1], Math.max(PACE_CLAMP[0], paceMedian));
+ const pace = {
+ value: clampedPace ?? 1,
+ n: paceRatios.length,
+ source: clampedPace !== null ? "learned" : "reference",
+ };
+
+ const bands = {};
+ let anyBandLearned = false;
+ for (const m of MILESTONES) {
+ const temps = tempsByMilestone[m];
+ if (temps.length >= MIN_BAND_SAMPLES) {
+ bands[m] = {
+ medianC: Math.round(median(temps)),
+ rangeC: [Math.round(Math.min(...temps)), Math.round(Math.max(...temps))],
+ n: temps.length,
+ source: "learned",
+ };
+ anyBandLearned = true;
+ } else {
+ bands[m] = { medianC: null, rangeC: null, n: temps.length, source: "reference" };
+ }
+ }
+
+ return {
+ pace,
+ bands,
+ bandsSource: anyBandLearned ? "learned" : "reference",
+ totalPlans: list.length,
+ };
+}
diff --git a/shared/ledger.js b/shared/ledger.js
index 755fd9b..b63e451 100644
--- a/shared/ledger.js
+++ b/shared/ledger.js
@@ -1,24 +1,42 @@
// Browser-safe. THE ONLY implementation of the worksheet's time-ledger math.
// Mirrors manual-roast-planner.html's worksheet box 6 (lines 1-9, totals A/C/D)
// and box 7 (the four sanity checks) exactly. Imported by both server and browser.
+//
+// Two changes from the original worksheet, made 2026-07-31 after a methodology review (see
+// shared/learn.js's header): first crack is now scaled by a multiplicative machine-pace factor —
+// the operator's own 14-roast data showed a ~13% batch-size increase stretching first crack ~40%,
+// a scaling relationship a flat added-seconds guess can't represent. Pace is applied to the WHOLE
+// pre-pace sum, INCLUDING line 4 (± batch-size correction), not just lines 1-3: shared/learn.js
+// fits pace from each historical roast's l1+l2+l3+l4 against what actually happened, so applying
+// it only to l1+l2+l3 here and adding l4 back afterwards would double-count whatever that
+// historical l4 was already compensating for. And the drying/Maillard sanity checks are now
+// marked `informational`: because yellow is DERIVED as YELLOW_RATIO × A rather than entered
+// independently, drying% and Maillard% are algebraic consequences of the DTR check, not
+// independent validations — whenever DTR passes, both are mathematically guaranteed to also pass.
+// Only DTR and the development ceiling test anything a plan could actually fail.
import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=__ASSET_VERSION__";
/**
* @param {import("./fields.js").Plan} plan
+ * @param {import("./learn.js").computeMachineProfile extends (...a:any)=>infer R ? R : never} [machineProfile]
+ * optional learned per-user profile from shared/learn.js; omitted or a profile with no learned
+ * data behaves exactly as before (pace 1.0 — the reference/original worksheet behavior).
* @returns ledger result — every time value in seconds, null when not computable.
*/
-export function computeLedger(plan) {
+export function computeLedger(plan, machineProfile = null) {
const f = plan.fields ?? {};
const isBlend = f["2.1"] === "blend";
const warnings = [];
+ const pace = machineProfile?.pace?.value ?? 1;
const l1 = parseRangeMidpoint(isBlend ? f["2.4"] : f["1.4"]);
const l2 = parseDuration(f["1.6"]) ?? 0;
const l3 = parseDuration(f["5.6"]) ?? 0;
const l4 = parseDuration(f["6.4"]) ?? 0;
- const A = l1 === null ? null : l1 + l2 + l3 + l4;
+ const baseA = l1 === null ? null : l1 + l2 + l3 + l4;
+ const A = baseA === null ? null : Math.round(baseA * pace);
const yellow = A === null ? null : Math.round(A * YELLOW_RATIO);
const maillard = A === null ? null : A - yellow;
@@ -36,8 +54,8 @@ export function computeLedger(plan) {
const pct = (part) => (D && D > 0 && part !== null ? (part / D) * 100 : null);
const checks = {
- drying: passCheck(pct(yellow), SANITY_BANDS.drying),
- maillard: passCheck(pct(maillard), SANITY_BANDS.maillard),
+ drying: { ...passCheck(pct(yellow), SANITY_BANDS.drying), informational: true },
+ maillard: { ...passCheck(pct(maillard), SANITY_BANDS.maillard), informational: true },
dtr: passCheck(pct(C), SANITY_BANDS.dtr),
ceiling: {
valueS: C,
@@ -49,6 +67,10 @@ export function computeLedger(plan) {
return {
isBlend,
lines: { l1, l2, l3, l4, l7, l8, l9 },
+ pace,
+ subtotalA: baseA, // lines 1-4 summed, before pace — exposed so the UI/print sheet can show
+ // the multiplication step explicitly instead of jumping straight to A with no visible way to
+ // reconstruct it from the printed lines (see the header comment above).
A,
yellow,
maillard,
diff --git a/shared/time.js b/shared/time.js
index d1af255..c2103a3 100644
--- a/shared/time.js
+++ b/shared/time.js
@@ -1,11 +1,16 @@
// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
const DASHES = /[‒–—−-]/; // -, –, —, minus sign
+// Matches only the non-ASCII members of DASHES — used to fold a leading minus/en/em dash down to
+// an ASCII hyphen before sign detection, so a string like "−0:30" (U+2212, the character every
+// signed number in this app's own reference data — SYMPTOM_FIXES, cultivar profiles — is written
+// with) parses instead of silently returning null.
+const NON_ASCII_DASHES = /[‒–—−]/g;
/** "8:45" -> 525, "-0:20" -> -20, "+0:15" -> 15, "0" -> 0, "90" -> 90 (bare seconds). null on junk. */
export function parseDuration(str) {
if (str === null || str === undefined) return null;
- const s = String(str).trim();
+ const s = String(str).trim().replace(NON_ASCII_DASHES, "-");
if (s === "") return null;
const sign = s.startsWith("-") ? -1 : 1;
const body = s.replace(/^[+-]/, "");
diff --git a/test/learn.test.js b/test/learn.test.js
new file mode 100644
index 0000000..e03699b
--- /dev/null
+++ b/test/learn.test.js
@@ -0,0 +1,170 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { computeMachineProfile } from "../shared/learn.js";
+
+function plan({
+ fcAnchor = "7:30",
+ refine = "0",
+ condition = "",
+ batchCorrection = "",
+ actualFc,
+ actualBt = {},
+} = {}) {
+ return {
+ fields: { "2.1": "single", "1.4": fcAnchor, "1.6": refine, "5.6": condition, "6.4": batchCorrection },
+ planActual: {
+ charge: { actualBt: actualBt.charge ?? "" },
+ tp: { actualBt: actualBt.tp ?? "" },
+ yellow: { actualBt: actualBt.yellow ?? "" },
+ fc: { actualTime: actualFc ?? "", actualBt: actualBt.fc ?? "" },
+ drop: { actualBt: actualBt.drop ?? "" },
+ },
+ };
+}
+
+test("learn: empty history stays at reference defaults", () => {
+ const profile = computeMachineProfile([]);
+ assert.equal(profile.pace.value, 1);
+ assert.equal(profile.pace.n, 0);
+ assert.equal(profile.pace.source, "reference");
+ assert.equal(profile.bandsSource, "reference");
+ assert.equal(profile.totalPlans, 0);
+ for (const m of ["charge", "tp", "yellow", "fc", "drop"]) {
+ assert.equal(profile.bands[m].source, "reference");
+ assert.equal(profile.bands[m].medianC, null);
+ }
+});
+
+test("learn: non-array input is treated as no history, not a throw", () => {
+ assert.doesNotThrow(() => computeMachineProfile(null));
+ assert.doesNotThrow(() => computeMachineProfile(undefined));
+ assert.equal(computeMachineProfile(null).totalPlans, 0);
+});
+
+test("learn: a single completed roast is not enough to trust — pace stays 1.0", () => {
+ // anchor 7:30 = 450s, actual FC 8:15 = 495s (ratio 1.1) — only one data point.
+ const profile = computeMachineProfile([plan({ fcAnchor: "7:30", actualFc: "8:15" })]);
+ assert.equal(profile.pace.value, 1);
+ assert.equal(profile.pace.n, 1);
+ assert.equal(profile.pace.source, "reference");
+});
+
+test("learn: two or more roasts running consistently slower learn a >1 pace factor", () => {
+ // Every roast ran 10% slower than its own anchor predicted.
+ const plans = [
+ plan({ fcAnchor: "7:30", actualFc: "8:15" }), // 450 -> 495, ratio 1.1
+ plan({ fcAnchor: "8:00", actualFc: "8:48" }), // 480 -> 528, ratio 1.1
+ plan({ fcAnchor: "9:00", actualFc: "9:54" }), // 540 -> 594, ratio 1.1
+ ];
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.pace.n, 3);
+ assert.equal(profile.pace.source, "learned");
+ assert.ok(Math.abs(profile.pace.value - 1.1) < 0.001, `expected ~1.1, got ${profile.pace.value}`);
+});
+
+test("learn: refine and bean-condition corrections are folded into the predicted anchor before pacing", () => {
+ // anchor 7:30 (450s) + refine +0:15 (15s) + condition -0:05 (-5s) = predicted 460s.
+ // Actual exactly matches the corrected prediction, so pace should read as 1.0 even though
+ // naively comparing actual against the raw uncorrected anchor would suggest otherwise.
+ const plans = [
+ plan({ fcAnchor: "7:30", refine: "+0:15", condition: "-0:05", actualFc: "7:40" }), // 460s
+ plan({ fcAnchor: "8:00", refine: "+0:15", condition: "-0:05", actualFc: "8:10" }), // 490s
+ ];
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.pace.source, "learned");
+ assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`);
+});
+
+test("learn: a plan with no anchor or no actual FC time is silently skipped, not counted or thrown on", () => {
+ const plans = [
+ plan({ fcAnchor: "", actualFc: "8:15" }), // no anchor -> unpredictable, skipped
+ plan({ fcAnchor: "7:30", actualFc: "" }), // never roasted -> skipped
+ { fields: {}, planActual: null }, // malformed -> skipped
+ {}, // completely empty -> skipped
+ ];
+ assert.doesNotThrow(() => computeMachineProfile(plans));
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.pace.n, 0);
+ assert.equal(profile.pace.source, "reference");
+ assert.equal(profile.totalPlans, 4);
+});
+
+test("learn: temperature bands require 3+ readings per milestone, independently per milestone", () => {
+ const plans = [
+ plan({ actualBt: { charge: "160", fc: "180" } }),
+ plan({ actualBt: { charge: "165", fc: "185" } }),
+ plan({ actualBt: { charge: "170", fc: "190" } }), // charge and fc now have 3 each
+ plan({ actualBt: { tp: "85" } }), // tp only has 1 reading
+ ];
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.bands.charge.source, "learned");
+ assert.equal(profile.bands.charge.n, 3);
+ assert.equal(profile.bands.charge.medianC, 165);
+ assert.deepEqual(profile.bands.charge.rangeC, [160, 170]);
+ assert.equal(profile.bands.fc.source, "learned");
+ assert.equal(profile.bands.fc.medianC, 185);
+ // Below the 3-sample threshold — stays reference, not a false "learned" reading from 1 point.
+ assert.equal(profile.bands.tp.source, "reference");
+ assert.equal(profile.bands.tp.medianC, null);
+ assert.equal(profile.bands.tp.n, 1);
+ assert.equal(profile.bandsSource, "learned"); // true because AT LEAST ONE milestone learned
+});
+
+test("learn: non-numeric actualBt values are ignored rather than poisoning the median", () => {
+ const plans = [
+ plan({ actualBt: { charge: "160" } }),
+ plan({ actualBt: { charge: "not a number" } }),
+ plan({ actualBt: { charge: "" } }),
+ plan({ actualBt: { charge: "170" } }),
+ plan({ actualBt: { charge: "180" } }),
+ ];
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.bands.charge.n, 3); // only the 3 valid numeric readings counted
+ assert.equal(profile.bands.charge.medianC, 170);
+});
+
+test("learn: field 6.4 is included in the fitted prediction, so it isn't double-counted once pace is learned", () => {
+ // anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s predicted; actual FC lands exactly there, every
+ // time, so a correct fit reads pace as 1.0 — NOT ~1.125, which is what a fit that excluded 6.4
+ // (comparing 540s actual against a 480s prediction) would wrongly report.
+ const plans = [
+ plan({ fcAnchor: "8:00", batchCorrection: "+1:00", actualFc: "9:00" }),
+ plan({ fcAnchor: "8:30", batchCorrection: "+1:00", actualFc: "9:30" }),
+ plan({ fcAnchor: "9:00", batchCorrection: "+1:00", actualFc: "10:00" }),
+ ];
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.pace.source, "learned");
+ assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`);
+});
+
+test("learn: a single wildly-off actual-FC entry is excluded as an outlier, not allowed to define the pace", () => {
+ // Second roast's actual FC (0:05) against an 8:00 anchor is a near-certain typo, not a real
+ // 96% speed-up — the ratio (0.0104) sits far outside the plausible band and must be dropped
+ // before the median, not clamped into it as if it were real signal from a small sample.
+ const plans = [
+ plan({ fcAnchor: "8:00", actualFc: "8:10" }), // ratio ~1.02, plausible
+ plan({ fcAnchor: "8:00", actualFc: "0:05" }), // ratio ~0.01, implausible — excluded
+ ];
+ const profile = computeMachineProfile(plans);
+ // Only one plausible sample remains, below MIN_PACE_SAMPLES — falls back to reference rather
+ // than "learning" a pace from what's actually just the one plausible reading.
+ assert.equal(profile.pace.n, 1);
+ assert.equal(profile.pace.source, "reference");
+ assert.equal(profile.pace.value, 1);
+});
+
+test("learn: the learned pace is clamped to a defensible band even with several agreeing samples", () => {
+ // Three roasts all reading a ~0.55 ratio — each one individually within the per-sample
+ // plausible band (so none get excluded as an outlier), but a median that far below 1 is still
+ // implausible for a real machine. The hard clamp exists for exactly this shape of history,
+ // where per-sample filtering alone wouldn't catch it.
+ const plans = [
+ plan({ fcAnchor: "8:00", actualFc: "4:20" }), // 260/480 = 0.542
+ plan({ fcAnchor: "8:00", actualFc: "4:24" }), // 264/480 = 0.55
+ plan({ fcAnchor: "8:00", actualFc: "4:28" }), // 268/480 = 0.558
+ ];
+ const profile = computeMachineProfile(plans);
+ assert.equal(profile.pace.n, 3); // all three were plausible per-sample, none excluded
+ assert.equal(profile.pace.source, "learned");
+ assert.equal(profile.pace.value, 0.6, "median ~0.55 should be clamped up to the 0.6 floor");
+});
diff --git a/test/ledger.test.js b/test/ledger.test.js
new file mode 100644
index 0000000..7927c3e
--- /dev/null
+++ b/test/ledger.test.js
@@ -0,0 +1,116 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { computeLedger } from "../shared/ledger.js";
+
+function plan(fields = {}) {
+ return {
+ fields: {
+ "2.1": "single",
+ "1.4": "8:00", // anchor: 480s
+ "1.6": "0", // refine
+ "5.6": "", // bean-condition
+ "6.4": "", // batch-size correction
+ "4.3": "2:00", // dev base: 120s
+ "3.2": "", // process modifier
+ "1.7": "", // cultivar dev modifier
+ ...fields,
+ },
+ };
+}
+
+test("ledger: no machineProfile argument behaves exactly as before (pace 1.0)", () => {
+ const withNone = computeLedger(plan());
+ const withNull = computeLedger(plan(), null);
+ const withExplicit1 = computeLedger(plan(), { pace: { value: 1 } });
+ assert.equal(withNone.A, 480);
+ assert.deepEqual(withNone, withNull);
+ assert.deepEqual(withNone, withExplicit1);
+});
+
+test("ledger: this is the one pre-existing caller's exact shape — server/prefill.js calls computeLedger({fields}) with no second argument", () => {
+ // Guards against a future change to computeLedger's signature silently breaking the only
+ // caller that doesn't know about machineProfile at all.
+ const ledger = computeLedger({ fields: plan().fields });
+ assert.equal(ledger.A, 480);
+ assert.equal(Array.isArray(ledger.warnings), true);
+});
+
+test("ledger: pace multiplies the whole pre-pace sum, including the manual batch correction", () => {
+ // anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s base; pace 0.9 -> A = round(540*0.9) = 486s = 8:06.
+ // This is the fix for the double-counting bug: pace applies to l4 too, not just l1-l3.
+ const ledger = computeLedger(plan({ "6.4": "+1:00" }), { pace: { value: 0.9 } });
+ assert.equal(ledger.A, 486);
+ assert.equal(ledger.lines.l4, 60);
+});
+
+test("ledger: subtotalA and pace are exposed and reconstruct A exactly — this is what the printed worksheet's disclosure rows display", () => {
+ const ledger = computeLedger(plan({ "6.4": "+1:00" }), { pace: { value: 0.9 } });
+ assert.equal(ledger.subtotalA, 540); // 480 + 60, before pace
+ assert.equal(ledger.pace, 0.9);
+ assert.equal(ledger.A, Math.round(ledger.subtotalA * ledger.pace));
+});
+
+test("ledger: pace of 1.1 on a plan with refine and bean-condition lines", () => {
+ // anchor 8:00 (480) + refine +0:15 (15) + condition -0:05 (-5) = 490s base; pace 1.1 -> round(539) = 539.
+ const ledger = computeLedger(plan({ "1.6": "+0:15", "5.6": "-0:05" }), {
+ pace: { value: 1.1 },
+ });
+ assert.equal(ledger.A, 539);
+});
+
+test("ledger: yellow and Maillard are derived from the paced A, not the pre-pace sum", () => {
+ const ledger = computeLedger(plan(), { pace: { value: 0.9 } });
+ assert.equal(ledger.A, 432); // 480 * 0.9
+ assert.equal(ledger.yellow, Math.round(432 * 0.56));
+ assert.equal(ledger.maillard, ledger.A - ledger.yellow);
+});
+
+test("ledger: a missing pace value on a malformed profile object falls back to 1.0, not NaN", () => {
+ const ledger = computeLedger(plan(), {});
+ assert.equal(ledger.A, 480);
+ assert.equal(Number.isNaN(ledger.A), false);
+});
+
+test("ledger: drying and Maillard checks are informational; only DTR and ceiling can actually fail", () => {
+ const ledger = computeLedger(plan());
+ assert.equal(ledger.checks.drying.informational, true);
+ assert.equal(ledger.checks.maillard.informational, true);
+ assert.equal(ledger.checks.dtr.informational, undefined);
+ assert.equal(ledger.checks.ceiling.informational, undefined);
+});
+
+test("ledger: drying and Maillard can never fail when DTR passes — the tautology the reclassification documents", () => {
+ // Sweep a range of plausible plans and confirm the algebraic guarantee: whenever DTR is
+ // in-band, drying and Maillard are too (this is *why* they're informational, not a check).
+ for (const anchor of ["7:00", "7:30", "8:00", "8:45", "9:30"]) {
+ for (const dev of ["1:30", "2:00", "2:45", "3:15"]) {
+ const ledger = computeLedger(plan({ "1.4": anchor, "4.3": dev }));
+ if (ledger.checks.dtr.pass) {
+ assert.equal(
+ ledger.checks.drying.pass,
+ true,
+ `anchor=${anchor} dev=${dev}: DTR passed but drying failed`,
+ );
+ assert.equal(
+ ledger.checks.maillard.pass,
+ true,
+ `anchor=${anchor} dev=${dev}: DTR passed but Maillard failed`,
+ );
+ }
+ }
+ }
+});
+
+test("ledger: no anchor means A, D and every downstream value are null, with a warning", () => {
+ const ledger = computeLedger(plan({ "1.4": "" }));
+ assert.equal(ledger.A, null);
+ assert.equal(ledger.yellow, null);
+ assert.equal(ledger.D, null);
+ assert.ok(ledger.warnings.some((w) => w.includes("cultivar/blend anchor")));
+});
+
+test("ledger: blend mode reads 2.4 instead of 1.4 for the anchor", () => {
+ const ledger = computeLedger(plan({ "2.1": "blend", "2.4": "8:30", "1.4": "1:00" }));
+ assert.equal(ledger.isBlend, true);
+ assert.equal(ledger.A, 510); // 8:30, not 1:00 -- 1.4 is ignored while blend is selected
+});
diff --git a/test/machine-profile.test.js b/test/machine-profile.test.js
new file mode 100644
index 0000000..73894e8
--- /dev/null
+++ b/test/machine-profile.test.js
@@ -0,0 +1,145 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import request from "supertest";
+import { setup, signup } from "./helpers.js";
+
+function planBody(overrides = {}) {
+ return {
+ fields: { "2.1": "single", "1.4": "7:30", "1.6": "0", "5.6": "" },
+ planActual: {
+ charge: { actualBt: "" },
+ tp: { actualBt: "" },
+ yellow: { actualBt: "" },
+ fc: { actualTime: "", actualBt: "" },
+ drop: { actualBt: "" },
+ },
+ afterRoast: { oneChange: "" },
+ inventory: { lotId: "", lotLabel: "", consumed: null },
+ ...overrides,
+ };
+}
+
+test("machine-profile: requires auth", async () => {
+ const { app } = await setup();
+ const anon = request.agent(app);
+ assert.equal((await anon.get("/api/machine-profile")).status, 401);
+});
+
+test("machine-profile: a brand-new account gets the reference defaults, not an error", async () => {
+ const { app } = await setup();
+ const agent = request.agent(app);
+ await signup(agent, "fresh@example.com");
+ const r = await agent.get("/api/machine-profile");
+ assert.equal(r.status, 200);
+ assert.equal(r.body.ok, true);
+ assert.equal(r.body.profile.pace.value, 1);
+ assert.equal(r.body.profile.pace.source, "reference");
+ assert.equal(r.body.profile.totalPlans, 0);
+});
+
+test("machine-profile: learns a pace factor from this account's own completed roasts, and only this account's", async () => {
+ const { app } = await setup();
+ const owner = request.agent(app);
+ const { csrf } = await signup(owner, "owner@example.com");
+ // Every roast here ran 20% slower than its own anchor (7:30=450s -> actual 9:00=540s, ratio 1.2).
+ await owner
+ .post("/api/plans")
+ .set("x-csrf-token", csrf)
+ .send({ plan: planBody({ planActual: { ...planBody().planActual, fc: { actualTime: "9:00", actualBt: "" } } }) });
+ await owner
+ .post("/api/plans")
+ .set("x-csrf-token", csrf)
+ .send({
+ plan: planBody({
+ fields: { "2.1": "single", "1.4": "8:00", "1.6": "0", "5.6": "" }, // 480s
+ planActual: { ...planBody().planActual, fc: { actualTime: "9:36", actualBt: "" } }, // 576s, ratio 1.2
+ }),
+ });
+
+ const other = request.agent(app);
+ await signup(other, "other@example.com");
+
+ const ownerProfile = (await owner.get("/api/machine-profile")).body.profile;
+ assert.equal(ownerProfile.pace.n, 2);
+ assert.equal(ownerProfile.pace.source, "learned");
+ assert.ok(Math.abs(ownerProfile.pace.value - 1.2) < 0.001, `expected ~1.2, got ${ownerProfile.pace.value}`);
+
+ // The other account's history is empty — it must not see the owner's learned pace.
+ const otherProfile = (await other.get("/api/machine-profile")).body.profile;
+ assert.equal(otherProfile.pace.source, "reference");
+ assert.equal(otherProfile.pace.value, 1);
+});
+
+test("last-refine: requires auth and 404s on a lot that doesn't belong to the caller", async () => {
+ const { app } = await setup();
+ const ownerAgent = request.agent(app);
+ const { csrf } = await signup(ownerAgent, "lotowner@example.com");
+ const lot = (
+ await ownerAgent
+ .post("/api/inventory")
+ .set("x-csrf-token", csrf)
+ .send({ origin: "Huila", initialWeightG: 1000 })
+ ).body.lot;
+
+ const anon = request.agent(app);
+ assert.equal((await anon.get(`/api/inventory/${lot.id}/last-refine`)).status, 401);
+
+ const otherAgent = request.agent(app);
+ await signup(otherAgent, "notlotowner@example.com");
+ assert.equal((await otherAgent.get(`/api/inventory/${lot.id}/last-refine`)).status, 404);
+});
+
+test("last-refine: null when no past roast against the lot has a filled-in one-change note", async () => {
+ const { app } = await setup();
+ const agent = request.agent(app);
+ const { csrf } = await signup(agent, "norefine@example.com");
+ const lot = (
+ await agent
+ .post("/api/inventory")
+ .set("x-csrf-token", csrf)
+ .send({ origin: "Huila", initialWeightG: 1000 })
+ ).body.lot;
+ await agent
+ .post("/api/plans")
+ .set("x-csrf-token", csrf)
+ .send({ plan: planBody({ inventory: { lotId: lot.id, lotLabel: "Huila", consumed: null } }) });
+
+ const r = await agent.get(`/api/inventory/${lot.id}/last-refine`);
+ assert.equal(r.status, 200);
+ assert.equal(r.body.refine, null);
+});
+
+test("last-refine: returns the most recent filled-in note for that lot, ignoring blank ones and other lots", async () => {
+ const { app } = await setup();
+ const agent = request.agent(app);
+ const { csrf } = await signup(agent, "refine@example.com");
+ const lotA = (
+ await agent.post("/api/inventory").set("x-csrf-token", csrf).send({ origin: "Lot A", initialWeightG: 1000 })
+ ).body.lot;
+ const lotB = (
+ await agent.post("/api/inventory").set("x-csrf-token", csrf).send({ origin: "Lot B", initialWeightG: 1000 })
+ ).body.lot;
+
+ // Older roast on lot A, with a note.
+ await agent.post("/api/plans").set("x-csrf-token", csrf).send({
+ plan: planBody({
+ inventory: { lotId: lotA.id, lotLabel: "Lot A", consumed: null },
+ afterRoast: { oneChange: "First crack +0:30" },
+ }),
+ });
+ // A blank-note roast on lot A (should never win over a real note, regardless of recency).
+ await agent.post("/api/plans").set("x-csrf-token", csrf).send({
+ plan: planBody({ inventory: { lotId: lotA.id, lotLabel: "Lot A", consumed: null } }),
+ });
+ // A roast on the OTHER lot with a note — must not leak into lot A's answer.
+ await agent.post("/api/plans").set("x-csrf-token", csrf).send({
+ plan: planBody({
+ inventory: { lotId: lotB.id, lotLabel: "Lot B", consumed: null },
+ afterRoast: { oneChange: "Development −0:15" },
+ }),
+ });
+
+ const r = await agent.get(`/api/inventory/${lotA.id}/last-refine`);
+ assert.equal(r.status, 200);
+ assert.equal(r.body.refine.oneChange, "First crack +0:30");
+});