Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve

- 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]>
This commit is contained in:
2026-07-29 15:41:35 -04:00
co-authored by Claude Sonnet 5
commit fedaf29847
22 changed files with 5000 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
// Browser-safe. Geometry for the roast-curve SVG grid (viewBox 0 0 714 334), matching
// manual-roast-planner.html's worksheet box 10 grid exactly.
import { MACHINE } from "./reference-data.js";
export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 };
export function tToX(seconds) {
const clamped = Math.max(0, Math.min(PLOT.tMaxS, seconds));
return PLOT.x0 + (clamped / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0);
}
// 60C -> y294 (bottom axis), 200C label sits at y42 (confirmed by the FC band rect y=65.4,h=19.8 = 176-187C)
export function tempToY(degC) {
return PLOT.y1 - (degC - 60) * 1.8;
}
export function rorToY(rorCPerMin) {
return PLOT.y1 - rorCPerMin * 9;
}
/** Build the five plan-curve points from the ledger + box-8 temps, falling back to machine medians. */
export function buildPlanCurve(plan, ledger) {
const temps = plan.temps ?? {};
const num = (v) => {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
};
const points = [
{ key: "charge", label: "Charge", timeS: 0, tempC: num(temps.charge?.tempC) ?? MACHINE.charge.medianC },
{ key: "tp", label: "Turning point", timeS: parseTpTime(temps.tp?.time), tempC: num(temps.tp?.tempC) ?? MACHINE.turningPoint.medianC },
{ key: "yellow", label: "Yellow", timeS: ledger.yellow, tempC: num(temps.yellow?.tempC) ?? MACHINE.yellow.medianC },
{ key: "fc", label: "First crack", timeS: ledger.A, tempC: num(temps.fc?.tempC) ?? MACHINE.firstCrack.medianC },
{ key: "drop", label: "Drop", timeS: ledger.D, tempC: num(temps.drop?.tempC) ?? MACHINE.drop.medianC },
];
return points.filter((p) => p.timeS !== null && p.timeS !== undefined);
}
function parseTpTime(str) {
if (!str) return 52; // MACHINE.turningPoint.medianTime, "0:52"
const m = String(str).match(/^(\d+):(\d{1,2})$/);
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
}
/** Build an SVG <path> "d" string through points, sorted by time, plain polyline (no kink invented). */
export function pointsToPathD(points) {
const sorted = [...points].sort((a, b) => a.timeS - b.timeS);
if (sorted.length === 0) return "";
return sorted
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.timeS).toFixed(1)},${tempToY(p.tempC).toFixed(1)}`)
.join(" ");
}
+71
View File
@@ -0,0 +1,71 @@
// Browser-safe. The allowlist of every scalar worksheet field, and a blank plan factory.
export const FIELD_IDS = [
"0.1", "0.2", "0.3", "0.4",
"1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7",
"2.1", "2.3", "2.4", "2.5",
"3.1", "3.2",
"4.1", "4.2", "4.3",
"5.1", "5.2", "5.3", "5.4", "5.5", "5.6",
"6.4",
];
export function blankPlan() {
const fields = {};
for (const id of FIELD_IDS) fields[id] = "";
fields["1.6"] = "0";
fields["2.1"] = "single";
return {
schemaVersion: 1,
fields,
blendComponents: [
{ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" },
{ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" },
],
temps: {
charge: { time: "0:00", tempC: "", tempF: "" },
tp: { time: "", tempC: "", tempF: "" },
yellow: { time: "", tempC: "", tempF: "" },
fc: { time: "", tempC: "", tempF: "" },
drop: { time: "", tempC: "", tempF: "" },
},
ror: {
tpToYellow: { value: "", ok: false, note: "" },
yellowToFc: { value: "", ok: false, note: "" },
fcToDrop: { value: "", ok: false, note: "" },
},
actuators: [
{ time: "0:00", heatPct: "", fanPct: "", expectedBt: "", why: "" },
{ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" },
{ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" },
],
planActual: {
charge: { planTime: "0:00", planBt: "", actualTime: "0:00", actualBt: "", ror: "", note: "" },
tp: { planTime: "", planBt: "", actualTime: "", actualBt: "", ror: "", note: "" },
yellow: { planTime: "", planBt: "", actualTime: "", actualBt: "", ror: "", note: "" },
fc: { planTime: "", planBt: "", actualTime: "", actualBt: "", ror: "", note: "" },
drop: { planTime: "", planBt: "", actualTime: "", actualBt: "", ror: "", note: "" },
},
afterRoast: {
greenIn: "", out: "", weightLossPct: "", vsTarget: "", actualDtrPct: "",
colour: "", restedDays: "", brewRatio: "", method: "",
cupNotes: "", cupNotesLine2: "", oneChange: "", disproof: "",
},
sanityOverride: { drying: null, maillard: null, dtr: null, ceiling: null },
reference: null,
prefill: null,
};
}
/** Keep only known FIELD_IDS, coerce every value to a string. */
export function sanitizeFieldPatch(patch) {
const out = {};
if (!patch || typeof patch !== "object") return out;
for (const id of FIELD_IDS) {
if (Object.prototype.hasOwnProperty.call(patch, id) && patch[id] !== null && patch[id] !== undefined) {
out[id] = String(patch[id]);
}
}
return out;
}
+69
View File
@@ -0,0 +1,69 @@
// 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,
};
}
+119
View File
@@ -0,0 +1,119 @@
// Browser-safe. Ported from /Users/shane/dev/hope_roaster/roast-planner/manual-roast-planner.html
// (Step 1 cultivar table, Step 3 processing table, Step 4 roast-level table, Step 8 machine bands,
// Step 7 sanity bands). Keep this the single source of truth — don't retype these numbers elsewhere.
import { parseRangeMidpoint } from "./time.js";
export const YELLOW_RATIO = 0.56;
const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share
export const GROUPS = [
{ key: "typica", label: "Typica", fcAnchor: "7:30-8:00" },
{ key: "bourbon", label: "Bourbon", fcAnchor: "8:30-9:30" },
{ key: "ethiopian", label: "Ethiopian", fcAnchor: "7:20-7:30" },
{ key: "hybrid", label: "Timor Hybrid", fcAnchor: "follows parent" },
];
// profile = [charge->yellow, yellow->FC, FC->drop], for a light roast. devModS is derived below.
export const CULTIVARS = [
{ name: "Typica", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
{ name: "Maragogipe", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
{ name: "Pache", group: "typica", fcAnchor: "7:30-8:00", profile: ["4:15", "3:15", "1:30"] },
{ name: "Pacamara", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15", "3:15-3:45", "1:45"] },
{ name: "SL-34", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
{ name: "Catuai", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15-5:00", "3:15-4:00", "1:30"] },
{ name: "Bourbon", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] },
{ name: "Caturra", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] },
{ name: "Pacas", group: "bourbon", fcAnchor: "8:45", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] },
{ name: "SL-28", group: "bourbon", fcAnchor: "8:00-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] },
{ name: "Ethiopian Heirloom", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
{ name: "Gesha", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
{ name: "Pink Bourbon", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:20"] },
{ name: "Sidra", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
{ name: "Papayo", group: "ethiopian", fcAnchor: "7:20", profile: ["4:20", "3:00", "1:20"] },
{ name: "Castillo", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:00", "1:30"] },
{ name: "Tabi", group: "hybrid", fcAnchor: "8:00", profile: ["4:15", "3:45", "1:30"] },
{ name: "H1 / Centroamericano", group: "hybrid", fcAnchor: "7:00", profile: ["4:15", "2:45", "1:30"] },
{ name: "Catimor", group: "hybrid", fcAnchor: "8:30", profile: ["4:45", "4:15", "1:30-2:00"] },
{ name: "Sarchimor", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:15", "1:30-2:00"] },
].map((c) => ({
...c,
devModS: parseRangeMidpoint(c.profile[2]) - DEFAULT_DEV_THIRD_S,
}));
export function findCultivar(name) {
if (!name) return null;
const norm = String(name).trim().toLowerCase().replace(/["'.]/g, "");
return (
CULTIVARS.find((c) => c.name.toLowerCase().replace(/["'.]/g, "") === norm) ??
CULTIVARS.find((c) => norm.includes(c.name.toLowerCase().split(" / ")[0])) ??
null
);
}
export function findGroup(key) {
return GROUPS.find((g) => g.key === key) ?? null;
}
export const PROCESSES = [
{ key: "washed", label: "Washed", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" },
{ key: "yellow-honey", label: "Yellow / white honey", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" },
{ key: "red-black-honey", label: "Red / black honey", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" },
{ key: "natural", label: "Natural", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" },
{ key: "anaerobic", label: "Anaerobic / CM", devBand: "-0:10 to -0:15 further", devModS: -30, dropDeltaC: "lower still" },
];
export function findProcess(key) {
return PROCESSES.find((p) => p.key === key) ?? null;
}
export const ROAST_LEVELS = [
{ key: "light", label: "Light", weightLossBand: "11-13%", devBand: "1:15-2:00" },
{ key: "light-medium", label: "Light-medium", weightLossBand: "13-14%", devBand: "2:00" },
{ key: "medium", label: "Medium", weightLossBand: "14-16%", devBand: "2:00-3:00" },
{ key: "dark", label: "Dark", weightLossBand: "17-18%", devBand: "3:00-4:00" },
{ key: "very-dark", label: "Very dark", weightLossBand: "19-21%", devBand: "3:30-4:30" },
];
export function findRoastLevel(key) {
return ROAST_LEVELS.find((r) => r.key === key) ?? null;
}
// Step 8 — from the operator's own 14 logged roasts.
export const MACHINE = {
charge: { medianC: 163, rangeC: [133, 195] },
turningPoint: { medianC: 85, medianTime: "0:52", rangeC: [77, 100], rangeTime: "0:47-1:15" },
yellow: { medianC: 158, rangeC: [148, 166] },
firstCrack: { medianC: 183, rangeC: [176, 187] },
drop: { medianC: 192, rangeC: [183, 197] },
ror: {
tpToYellow: [17, 25],
yellowToFc: [6, 10],
fcToDrop: [3, 9],
},
deadTimeS: 60,
fanEffectCPerMinPer10Pct: -1.3,
};
// Step 7 sanity bands.
export const SANITY_BANDS = {
drying: { lo: 43, hi: 51 },
maillard: { lo: 33, hi: 39 },
dtr: { lo: 12, hi: 20 },
ceilingS: 270, // 4:30
};
// Step 9 taper template — starting shape for box 9's actuator schedule.
export const TAPER_TEMPLATE = [
{ point: "Charge", heat: "90-100%", fan: "30-50%", intent: "Recover from the charge dip and drive to the turning point." },
{ point: "Hold, then first cut", heat: "-> 90-95%", fan: "hold", intent: "When you make this cut is your main first-crack lever." },
{ point: "Mid-drying", heat: "85-90%", fan: "50-60%", intent: "Begin the decline; steps of 3-5% beat single large ones." },
{ point: "At yellow", heat: "77-82%", fan: "60-70%", intent: "Airflow rising as chaff releases; RoR around 8-10 C/min." },
{ point: "Late Maillard", heat: "70-75%", fan: "70-80%", intent: "Get ahead of the exotherm here, not after it." },
{ point: "At first crack", heat: "60-70%", fan: "70-80%", intent: "Cut before the exotherm, gently." },
{ point: "Development", heat: "55-65%", fan: "80%", intent: "Small trims only." },
{ point: "Drop", heat: "0%", fan: "100%", intent: "Cool fast; log the drop time." },
];
+49
View File
@@ -0,0 +1,49 @@
// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
const DASHES = /[‒–—−-]/; // -, , —, minus sign
/** "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();
if (s === "") return null;
const sign = s.startsWith("-") ? -1 : 1;
const body = s.replace(/^[+-]/, "");
const m = body.match(/^(\d+):(\d{1,2})$/);
if (m) return sign * (Number(m[1]) * 60 + Number(m[2]));
if (/^\d+(\.\d+)?$/.test(body)) return sign * Math.round(Number(body));
return null;
}
/** "7:30-9:00" / "7:309:00" -> midpoint seconds. Single value passes through parseDuration. */
export function parseRangeMidpoint(str) {
if (str === null || str === undefined) return null;
const s = String(str).trim();
if (s === "") return null;
const parts = s.split(DASHES).map((p) => p.trim()).filter(Boolean);
if (parts.length >= 2) {
const a = parseDuration(parts[0]);
const b = parseDuration(parts[parts.length - 1]);
if (a === null || b === null) return null;
return Math.round((a + b) / 2);
}
return parseDuration(s);
}
/** 525 -> "8:45". Negative seconds -> "-0:20". null -> "". */
export function formatDuration(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined || Number.isNaN(totalSeconds)) return "";
const sign = totalSeconds < 0 ? "-" : "";
const abs = Math.abs(Math.round(totalSeconds));
const m = Math.floor(abs / 60);
const s = abs % 60;
return `${sign}${m}:${String(s).padStart(2, "0")}`;
}
/** Same as formatDuration but always carries an explicit sign, and "0" for zero. */
export function formatSigned(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined || Number.isNaN(totalSeconds)) return "";
if (totalSeconds === 0) return "0";
const out = formatDuration(Math.abs(totalSeconds));
return totalSeconds < 0 ? `-${out}` : `+${out}`;
}