- shared/ ports the worksheet's ledger math, time parsing, and reference tables (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported by both the server and the browser so the arithmetic can't drift between them. - server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts from a bean product URL, then derives worksheet field IDs deterministically from reference-data.js — the model never invents a first-crack anchor or a modifier. - server/alog.js ports the Python alog_parser.py's format handling, including the tokenizer-based Python-dict-literal-to-JSON conversion the real files need. - public/ is the worksheet reproduced as a live HTML form: worksheet.css is a verbatim copy of the paper worksheet's print CSS, print.js bakes values into the print layout so the same DOM renders both on screen and on paper. - Ledger math verified against both of the paper worksheet's worked examples; alog parser verified against all 14 real logs in ref/roasts/. Co-Authored-By: Claude Sonnet 5 <[email protected]>
70 lines
2.2 KiB
JavaScript
70 lines
2.2 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.
|
|
|
|
import { parseDuration, parseRangeMidpoint } from "./time.js";
|
|
import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js";
|
|
|
|
/**
|
|
* @param {import("./fields.js").Plan} plan
|
|
* @returns ledger result — every time value in seconds, null when not computable.
|
|
*/
|
|
export function computeLedger(plan) {
|
|
const f = plan.fields ?? {};
|
|
const isBlend = f["2.1"] === "blend";
|
|
const warnings = [];
|
|
|
|
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 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),
|
|
maillard: passCheck(pct(maillard), SANITY_BANDS.maillard),
|
|
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 },
|
|
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,
|
|
};
|
|
}
|