import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__"; import { buildPlanCurve, pointsToPathD, curveWarnings, } from "./curve.js?v=__ASSET_VERSION__"; import { formatDuration } from "./time.js?v=__ASSET_VERSION__"; export const escapeHtml = (v) => String(v ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ], ); const e = escapeHtml, time = (s) => (s == null ? "—" : formatDuration(s)), f = (c) => (Number.isFinite(c) ? `${Math.round(c * 1.8 + 32)}°F` : "—"); const number = (v) => v != null && String(v).trim() !== "" && Number.isFinite(Number(v)) ? Number(v) : null; export function renderReportCurve(points, ledger, reference = null) { const ref = (reference?.curve ?? []).filter( (p) => Number.isFinite(p.t) && p.t >= 0 && Number.isFinite(p.bt), ); const maxT = Math.max(660, ...points.map((p) => p.timeS), ...ref.map((p) => p.t)) + 60; const minC = Math.floor( (Math.min(80, ...points.map((p) => p.tempC), ...ref.map((p) => p.bt)) - 10) / 20, ) * 20; const maxC = Math.ceil( (Math.max(220, ...points.map((p) => p.tempC), ...ref.map((p) => p.bt)) + 15) / 20, ) * 20; const x = (t) => 70 + (t / maxT) * 740, y = (c) => 365 - ((c - minC) / (maxC - minC)) * 250; let svg = ''; const phases = [ ["Drying", 0, ledger.yellow, "#f6efe7"], ["Maillard", ledger.yellow, ledger.A, "#efe6d8"], ["Development", ledger.A, ledger.D, "#e9ddd0"], ]; phases.forEach(([name, a, b, color], i) => { if (a == null || b == null) return; svg += `${name} · ${time(b - a)}${time(a)} → ${time(b)}`; }); for (let c = minC; c <= maxC; c += 20) svg += `${f(c)}`; const step = Math.ceil(maxT / 720) * 60; for (let t = 0; t <= maxT; t += step) svg += `${time(t)}`; if (ref.length) svg += ``; svg += ``; points.forEach((p, i) => { const lx = 80 + i * 164, ly = i % 2 ? 85 : 65; svg += `${e(p.label)}${time(p.timeS)} · ${f(p.tempC)}${p.illustrative ? "*" : ""}`; }); return ( svg + 'Time from charge · BT (°F) · * illustrative · dashed = attached reference' ); } export const REPORT_CSS = ` .roast-report{color:#161616;background:#ececea;font:12px/1.45 system-ui,Arial,sans-serif;padding:24px;box-sizing:border-box} .roast-report *{box-sizing:border-box}.roast-report .sheet{max-width:8.5in;margin:0 auto 24px;background:white;padding:32px 38px;box-shadow:0 6px 28px #0002} .roast-report h1{font-size:25px;line-height:1.12;margin:0 0 8px}.roast-report h2{display:block;border:0;padding:0;font-size:13px;text-transform:uppercase;letter-spacing:.08em;margin:18px 0 8px}.roast-report p{margin:7px 0}.roast-report .subtitle{color:#626262;border-bottom:3px solid #161616;padding-bottom:14px;margin-bottom:16px} .roast-report .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.roast-report .card{border:1px solid #d7d7d7;border-radius:9px;padding:11px}.roast-report .label{font-size:10px;color:#626262;text-transform:uppercase}.roast-report .value{font-size:20px;font-weight:700}.roast-report .callout{background:#f5f5f2;border-left:4px solid #7a4c2a;padding:10px 12px;margin:12px 0} .roast-report table{width:100%;border-collapse:collapse;font-size:11px}.roast-report th{text-align:left;border-bottom:2px solid #161616;font-size:10px}.roast-report td{border-bottom:1px solid #d7d7d7}.roast-report th,.roast-report td{padding:7px 5px;vertical-align:top}.roast-report svg{display:block;width:100%;height:auto}.roast-report .chart{border:1px solid #d7d7d7;border-radius:10px;padding:6px;break-inside:avoid}.roast-report .notes{white-space:pre-wrap;min-height:45px;border-bottom:1px solid #bbb}.roast-report .footer{margin-top:16px;padding-top:8px;border-top:1px solid #ddd;font-size:10px;color:#626262}.roast-report a{color:#6c482b;overflow-wrap:anywhere}.roast-report .cards,.roast-report tr{break-inside:avoid} @page{size:Letter;margin:.35in}@media print{.roast-report{background:white;padding:0;font-size:10px}.roast-report .sheet{width:auto;max-width:none;margin:0;padding:.16in .2in;box-shadow:none}.roast-report .sheet+.sheet{break-before:auto;padding-top:18px}.roast-report h2{margin-top:12px}.roast-report th,.roast-report td{padding:5px}.roast-report .chart{max-width:100%;max-height:3.1in}.roast-report .chart svg{height:auto;max-height:2.95in}.roast-report h1,.roast-report h2{break-after:avoid}.roast-report .record-intro{break-inside:avoid}.roast-report .footer{break-before:avoid}.roast-report p{orphans:3;widows:3}.roast-report a{color:inherit;text-decoration:none}.roast-report p{margin:5px 0}.roast-report .notes{min-height:40px}.roast-report .record-notes{display:grid;grid-template-columns:1fr 1fr;gap:18px;break-inside:avoid}.roast-report .card{padding:8px}.roast-report .value{font-size:18px}.roast-report table{font-size:10px}.roast-report .footer{margin-top:10px}.roast-report h1{font-size:23px}.roast-report .subtitle{padding-bottom:8px;margin-bottom:10px}.roast-report .callout{padding:8px 10px;margin:8px 0}} `; export function renderReportBody(plan, name = "") { const l = computeLedger(plan), p = buildPlanCurve(plan, l), fields = plan.fields ?? {}, after = plan.afterRoast ?? {}; const green = number(fields["0.4"]), loss = number(fields["4.2"]); const out = green !== null && green > 0 && loss !== null && loss >= 0 && loss < 100 ? Math.round(green * (1 - loss / 100)) : null; const card = (label, value) => `
${e(label)}
${e(value)}
`; const cues = { charge: "Stable preheat; record charge BT separately from the preheat setpoint.", tp: "Probe recovery, not a direct measure of energy absorbed by the beans.", yellow: "Beans yellow; grassy aroma fades.", fc: "Mark sustained audible first crack; do not chase the illustrated temperature.", drop: "Judge color, aroma and progression; cool promptly. DTR alone is not a drop signal.", }; const warnings = [...l.warnings, ...curveWarnings(p)]; const rows = p .map( (v) => `${e(v.label)}${time(v.timeS)}${f(v.tempC)}${v.illustrative ? "*" : ""}${cues[v.key]}`, ) .join(""); const schedule = (plan.actuators ?? []).filter( (v) => v.heatPct || v.fanPct || v.why || v.expectedBt, ); const sr = schedule.length ? schedule .map( (v) => `${e(v.time)}${e(v.heatPct || "—")}%${e(v.fanPct || "—")}%${f(number(v.expectedBt))}${e(v.why)}`, ) .join("") : 'Record your heat and airflow steps in the planner. No universal actuator percentages are prescribed.'; return `

${e(name || fields["0.1"] || "Roast plan")}

${e([fields["1.1"], fields["3.1"], fields["4.1"] ? fields["4.1"] + " roast" : ""].filter(Boolean).join(" · "))} · Planning guide
${card("Green coffee in", green === null ? "—" : green + " g")}${card("Target roasted out", out === null ? "—" : out + " g")}${card("Target total time", time(l.D))}

Coffee & roast intent

Origin: ${e(fields["1.3"] || "—")} · Cultivar: ${e(fields["1.1"] || "—")} · Density: ${e(fields["5.2"] || "—")} g/L

Weight-loss target: ${e(fields["4.2"] || "—")}% · Development: ${time(l.C)} · DTR: ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%

Use this as a starting hypothesis. Light, fruity coffee depends on the green coffee, roast progression and cup results. Machine selection never changes this plan. * Blank temperatures use an illustrative reference curve, not measured or calibrated targets.
${warnings.length ? `
${warnings.map((w) => `

${e(w)}

`).join("")}
` : ""}

Milestone plan

${rows}
MilestoneTimeBT (°F)What to look for

Roast curve

${renderReportCurve(p, l)}

The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.

Roast schedule & record

${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster

Preheat

${card("Setpoint (°F)", number(plan.preheat?.tempC) === null ? "________" : f(number(plan.preheat.tempC)))}${card("Preheat time (min)", plan.preheat?.minutes || "________")}${card("Drum / fan (%)", (plan.preheat?.drumPct || "____") + " / " + (plan.preheat?.fanPct || "____"))}

Follow a repeatable preheat routine. Record setpoint separately from the charge BT shown on the curve.

Actuator schedule

${sr}
WhenHeatFanBT (°F)Purpose
Watch RoR, color and aroma. Anticipate the machine’s response and avoid abrupt late cuts. Test one change in the next batch.

At-the-roaster record

${p .map((v) => { const a = plan.planActual?.[v.key] ?? {}; return ``; }) .join("")}
MilestonePlan time / BTActual timeActual BT (°F)Notes
${e(v.label)}${time(v.timeS)} / ${f(v.tempC)}${v.illustrative ? "*" : ""}${e(a.actualTime || "________")}${number(a.actualBt) === null ? "________" : f(number(a.actualBt))}${e(a.note || "________________")}

After the roast

${card("Roasted out", after.out ? after.out + " g" : "________ g")}${card("Weight loss", after.weightLossPct ? after.weightLossPct + "%" : "________ %")}${card("Rest before tasting", after.restedDays ? after.restedDays + " days" : "________ days")}

Cup / brew notes

${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}

One change for the next batch

${e(after.oneChange)}

Sources & interpretation

Sweet Maria’s: First Crack FAQ — audible event and temperature context.

Scott Rao: Coffee Roasting Fundamentals — probe readings do not transfer directly; time and DTR alone do not establish quality.

Cultivar timings, the 1:15 light-development suggestion floor and the illustrated curve are planning heuristics. Your explicit development override takes precedence.

`; } export function renderReportHtml(plan, name = "") { return `${e(name || plan.fields?.["0.1"] || "Roast plan")}${renderReportBody(plan, name)}`; }