import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__"; import { buildPlanCurve, planTemperatureOffsetF, 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 = '' ); } 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) => `
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) ?? "—"}%
${e(w)}
`).join("")}Gauge temperature offset: ${planTemperatureOffsetF(plan) > 0 ? "+" : ""}${e(planTemperatureOffsetF(plan))}°F. Applied to illustrative temperature references; drop follows adjusted milestones. Entered targets, preheat and actual readings are unchanged.
` : ""}| Milestone | Time | BT (°F) | What to look for |
|---|
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.
Follow a repeatable preheat routine. Record setpoint separately from the charge BT shown on the curve.
| When | Heat | Fan | BT (°F) | Purpose |
|---|
| Milestone | Plan time / BT | Actual time | Actual 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 || "________________")} |
Coffee source: ${e(plan.sourceUrl)}
` : ""}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.