Add per-user learned machine profile; fix methodology honesty issues
Test and deploy / test-and-deploy (push) Successful in 1m26s
Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -1625,6 +1625,28 @@ body.show-fids .field[data-fid]::after {
|
||||
.check-tile.fail .check-why {
|
||||
display: block;
|
||||
}
|
||||
.check-tile.informational .check-why {
|
||||
display: block;
|
||||
}
|
||||
.check-tile.informational .check-band {
|
||||
font-style: italic;
|
||||
}
|
||||
.refine-suggestion {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--ember-soft);
|
||||
background: var(--ember-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.refine-suggestion span {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dock-note {
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
@@ -2796,6 +2818,10 @@ body.show-fids .field[data-fid]::after {
|
||||
td.passcell.fail::before {
|
||||
content: "✗ ";
|
||||
}
|
||||
td.passcell.informational::before {
|
||||
content: "— ";
|
||||
color: var(--mid);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Legacy .opt styling reused by the (hidden-on-screen) print worksheet's own radios ── */
|
||||
|
||||
+58
-11
@@ -499,7 +499,17 @@
|
||||
>0 the first time you roast this coffee. Afterwards,
|
||||
carry "One change next batch" here — this is how the
|
||||
plan learns.</span
|
||||
></label
|
||||
>
|
||||
<div class="refine-suggestion hidden" id="refine-suggestion">
|
||||
<span id="refine-suggestion-text"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn small hidden"
|
||||
id="btn-apply-refine"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div></label
|
||||
>
|
||||
<label class="field" data-fid="1.7"
|
||||
><span class="field-label">± Dev modifier</span
|
||||
@@ -1242,6 +1252,7 @@
|
||||
Drying + Maillard + Development = the whole roast. Cultivar
|
||||
sets first crack; process and level set development.
|
||||
</p>
|
||||
<p class="dock-note machine-profile-note" id="machine-profile-note"></p>
|
||||
|
||||
<div class="ledger-group">
|
||||
<div class="ledger-row">
|
||||
@@ -1270,6 +1281,16 @@
|
||||
title="225 g lands first crack ~1:30–2:00 later than 200 g"
|
||||
/>
|
||||
</div>
|
||||
<div class="ledger-row ledger-subtotal">
|
||||
<span class="l-fid"></span
|
||||
><span class="l-label">Subtotal (before pace)</span
|
||||
><output class="l-val" data-out="subtotalA">—</output>
|
||||
</div>
|
||||
<div class="ledger-row ledger-pace">
|
||||
<span class="l-fid"></span
|
||||
><span class="l-label">× Machine pace</span
|
||||
><output class="l-val" data-out="pace-factor">×1.00</output>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ledger-total hero">
|
||||
<span>First crack</span><output data-out="A">—</output>
|
||||
@@ -1325,19 +1346,22 @@
|
||||
<div class="check-tile unknown" data-pass="drying">
|
||||
<span class="check-name">Drying share</span>
|
||||
<output class="check-value" data-out="check-drying">—</output>
|
||||
<span class="check-band">target 43–51%</span>
|
||||
<span class="check-band">informational, target 43–51%</span>
|
||||
<span class="check-why"
|
||||
>high = late yellow, browning starved · low = rushed
|
||||
before internal pressure built</span
|
||||
>Derived 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.</span
|
||||
>
|
||||
</div>
|
||||
<div class="check-tile unknown" data-pass="maillard">
|
||||
<span class="check-name">Maillard share</span>
|
||||
<output class="check-value" data-out="check-maillard">—</output>
|
||||
<span class="check-band">target 33–39%</span>
|
||||
<span class="check-band">informational, target 33–39%</span>
|
||||
<span class="check-why"
|
||||
>low = too small a browning window for sweetness and
|
||||
complexity</span
|
||||
>Same derivation as drying share — informational, not an
|
||||
independent check. Low = too small a browning window for
|
||||
sweetness and complexity.</span
|
||||
>
|
||||
</div>
|
||||
<div class="check-tile unknown" data-pass="dtr">
|
||||
@@ -1878,6 +1902,22 @@
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="row">
|
||||
<td class="ln"></td>
|
||||
<td class="desc">Subtotal (before pace)</td>
|
||||
<td class="val"><output data-out="subtotalA">—</output></td>
|
||||
</tr>
|
||||
<tr class="row">
|
||||
<td class="ln"></td>
|
||||
<td class="desc">
|
||||
<span class="op">×</span> Machine pace
|
||||
<span class="sub"
|
||||
>learned from your own roasts, else ×1.00
|
||||
(reference)</span
|
||||
>
|
||||
</td>
|
||||
<td class="val"><output data-out="pace-factor">×1.00</output></td>
|
||||
</tr>
|
||||
<tr class="total">
|
||||
<td class="ln">A</td>
|
||||
<td class="desc"><strong>FIRST CRACK</strong></td>
|
||||
@@ -1964,16 +2004,16 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Drying share</td>
|
||||
<td>Drying share (informational)</td>
|
||||
<td class="num">43–51 %</td>
|
||||
<td class="num">
|
||||
<output data-out="check-drying">—</output>
|
||||
</td>
|
||||
<td class="num passcell unknown" data-pass="drying"></td>
|
||||
<td>high = late yellow · low = rushed</td>
|
||||
<td>can't fail on its own — see * below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Maillard share</td>
|
||||
<td>Maillard share (informational)</td>
|
||||
<td class="num">33–39 %</td>
|
||||
<td class="num">
|
||||
<output data-out="check-maillard">—</output>
|
||||
@@ -1982,7 +2022,7 @@
|
||||
class="num passcell unknown"
|
||||
data-pass="maillard"
|
||||
></td>
|
||||
<td>low = too little browning</td>
|
||||
<td>can't fail on its own — see * below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Development ratio</td>
|
||||
@@ -2004,6 +2044,13 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="wsfoot">
|
||||
* Drying share and Maillard share are always in-band
|
||||
whenever Development ratio passes — yellow is calculated as
|
||||
a fixed 56% of first crack, not measured independently, so
|
||||
these two rows can't fail on their own. Only Development
|
||||
ratio and Development ceiling are real pass/fail checks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+119
-53
@@ -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 " +
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+68
-2
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
+74
-10
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user