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]>
92 lines
4.1 KiB
JavaScript
92 lines
4.1 KiB
JavaScript
// 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, 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 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;
|
||
|
||
const l7 = parseRangeMidpoint(f["4.3"]);
|
||
const l8 = parseDuration(isBlend ? f["2.5"] : f["3.2"]) ?? 0;
|
||
const l9 = parseDuration(f["1.7"]) ?? 0;
|
||
const C = l7 === null ? null : l7 + l8 + l9; // rows 5 and 6 (yellow/Maillard) are NEVER summed here
|
||
|
||
const D = A === null || C === null ? null : A + C;
|
||
|
||
if (l1 === null) warnings.push("No cultivar/blend anchor (field 1.4 or 2.4) — first crack cannot be computed.");
|
||
if (l7 === null) warnings.push("No development base (field 4.3) — development cannot be computed.");
|
||
|
||
const pct = (part) => (D && D > 0 && part !== null ? (part / D) * 100 : null);
|
||
|
||
const checks = {
|
||
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,
|
||
maxS: SANITY_BANDS.ceilingS,
|
||
pass: C === null ? null : C < SANITY_BANDS.ceilingS,
|
||
},
|
||
};
|
||
|
||
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,
|
||
C,
|
||
D,
|
||
checks,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
function passCheck(pct, band) {
|
||
return {
|
||
pct,
|
||
lo: band.lo,
|
||
hi: band.hi,
|
||
pass: pct === null ? null : pct >= band.lo && pct <= band.hi,
|
||
};
|
||
}
|