143 lines
12 KiB
JavaScript
143 lines
12 KiB
JavaScript
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 =
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 440" role="img" aria-label="Roast curve with milestone times, Fahrenheit temperatures and phase durations"><rect width="900" height="440" fill="white"/>';
|
||
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 += `<rect x="${x(a)}" y="105" width="${Math.max(0, x(b) - x(a))}" height="260" fill="${color}"/><text x="${80 + i * 280}" y="24" font-size="13" font-weight="700">${name} · ${time(b - a)}</text><text x="${80 + i * 280}" y="44" font-size="12">${time(a)} → ${time(b)}</text>`;
|
||
});
|
||
for (let c = minC; c <= maxC; c += 20)
|
||
svg += `<path d="M70 ${y(c)}H810" stroke="#ddd"/><text x="60" y="${y(c) + 4}" text-anchor="end" font-size="11">${f(c)}</text>`;
|
||
const step = Math.ceil(maxT / 720) * 60;
|
||
for (let t = 0; t <= maxT; t += step)
|
||
svg += `<path d="M${x(t)} 105V365" stroke="#ddd"/><text x="${x(t)}" y="390" text-anchor="middle" font-size="11">${time(t)}</text>`;
|
||
if (ref.length)
|
||
svg += `<path d="${ref.map((p, i) => `${i ? "L" : "M"}${x(p.t)},${y(p.bt)}`).join(" ")}" fill="none" stroke="#8b6b4a" stroke-width="2" stroke-dasharray="5 4"/>`;
|
||
svg += `<path d="${pointsToPathD(points, x, y)}" fill="none" stroke="#25201b" stroke-width="3"/>`;
|
||
points.forEach((p, i) => {
|
||
const lx = 80 + i * 164,
|
||
ly = i % 2 ? 85 : 65;
|
||
svg += `<path d="M${x(p.timeS)} ${y(p.tempC)}L${lx + 30} ${ly + 17}" stroke="#9d8976" stroke-dasharray="3 3"/><circle cx="${x(p.timeS)}" cy="${y(p.tempC)}" r="4" fill="white" stroke="#25201b" stroke-width="2"/><text x="${lx}" y="${ly}" font-size="11" font-weight="700">${e(p.label)}<tspan x="${lx}" dy="15" font-weight="400">${time(p.timeS)} · ${f(p.tempC)}${p.illustrative ? "*" : ""}</tspan></text>`;
|
||
});
|
||
return (
|
||
svg +
|
||
'<text x="450" y="425" text-anchor="middle" font-size="12">Time from charge · BT (°F) · * illustrative · dashed = attached reference</text></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:page}.roast-report h2{margin-top:12px}.roast-report th,.roast-report td{padding:5px}.roast-report .chart{max-width:100%}.roast-report a{color:inherit;text-decoration:none}.roast-report p{margin:5px 0}.roast-report .notes{min-height:32px}.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}}
|
||
`;
|
||
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) =>
|
||
`<div class="card"><div class="label">${e(label)}</div><div class="value">${e(value)}</div></div>`;
|
||
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) =>
|
||
`<tr><td><b>${e(v.label)}</b></td><td>${time(v.timeS)}</td><td>${f(v.tempC)}${v.illustrative ? "*" : ""}</td><td>${cues[v.key]}</td></tr>`,
|
||
)
|
||
.join("");
|
||
const schedule = (plan.actuators ?? []).filter(
|
||
(v) => v.heatPct || v.fanPct || v.why || v.expectedBt,
|
||
);
|
||
const sr = schedule.length
|
||
? schedule
|
||
.map(
|
||
(v) =>
|
||
`<tr><td>${e(v.time)}</td><td>${e(v.heatPct || "—")}%</td><td>${e(v.fanPct || "—")}%</td><td>${f(number(v.expectedBt))}</td><td>${e(v.why)}</td></tr>`,
|
||
)
|
||
.join("")
|
||
: '<tr><td colspan="5">Record your heat and airflow steps in the planner. No universal actuator percentages are prescribed.</td></tr>';
|
||
return `<div class="roast-report"><section class="sheet"><h1>${e(name || fields["0.1"] || "Roast plan")}</h1><div class="subtitle">${e([fields["1.1"], fields["3.1"], fields["4.1"] ? fields["4.1"] + " roast" : ""].filter(Boolean).join(" · "))} · Planning guide</div>
|
||
<div class="cards">${card("Green coffee in", green === null ? "—" : green + " g")}${card("Target roasted out", out === null ? "—" : out + " g")}${card("Target total time", time(l.D))}</div>
|
||
<h2>Coffee & roast intent</h2><p><b>Origin:</b> ${e(fields["1.3"] || "—")} · <b>Cultivar:</b> ${e(fields["1.1"] || "—")} · <b>Density:</b> ${e(fields["5.2"] || "—")} g/L</p><p><b>Weight-loss target:</b> ${e(fields["4.2"] || "—")}% · <b>Development:</b> ${time(l.C)} · <b>DTR:</b> ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%</p>
|
||
<div class="callout">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.</div>
|
||
${warnings.length ? `<div class="callout">${warnings.map((w) => `<p>${e(w)}</p>`).join("")}</div>` : ""}
|
||
<h2>Milestone plan</h2><table><thead><tr><th>Milestone</th><th>Time</th><th>BT (°F)</th><th>What to look for</th></tr></thead><tbody>${rows}</tbody></table>
|
||
<h2>Roast curve</h2><div class="chart">${renderReportCurve(p, l)}</div><p>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.</p><div class="footer">Roast plan · Grams & Fahrenheit · Page 1</div></section>
|
||
<section class="sheet"><h1>Roast schedule & record</h1><div class="subtitle">${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster</div>
|
||
<h2>Preheat</h2><div class="cards">${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 || "____"))}</div><p>Use your manufacturer’s guidance and a repeatable preheat routine. The charge BT plotted on page 1 is not a preheat setpoint. Record preheat separately.</p>
|
||
<h2>Actuator schedule</h2><table><thead><tr><th>When</th><th>Heat</th><th>Fan</th><th>BT (°F)</th><th>Purpose</th></tr></thead><tbody>${sr}</tbody></table>
|
||
<div class="callout">Watch actual RoR, color and aroma. Plan heat changes ahead of the machine’s response; avoid abrupt late cuts. Record one change to evaluate in the next cup.</div>
|
||
<h2>At-the-roaster record</h2><table><thead><tr><th>Milestone</th><th>Plan time / BT</th><th>Actual time</th><th>Actual BT (°F)</th><th>Notes</th></tr></thead><tbody>${p
|
||
.map((v) => {
|
||
const a = plan.planActual?.[v.key] ?? {};
|
||
return `<tr><td>${e(v.label)}</td><td>${time(v.timeS)} / ${f(v.tempC)}${v.illustrative ? "*" : ""}</td><td>${e(a.actualTime || "________")}</td><td>${number(a.actualBt) === null ? "________" : f(number(a.actualBt))}</td><td>${e(a.note || "________________")}</td></tr>`;
|
||
})
|
||
.join("")}</tbody></table>
|
||
<h2>After the roast</h2><div class="cards">${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")}</div>
|
||
<h2>Cup / brew notes</h2><div class="notes">${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}</div><h2>One change for the next batch</h2><div class="notes">${e(after.oneChange)}</div>
|
||
<h2>Sources & interpretation</h2><p><a href="https://library.sweetmarias.com/first-crack-faq-what-is-first-crack-what-is-second-crack/">Sweet Maria’s: First Crack FAQ</a> — audible event and temperature context.</p><p><a href="https://www.scottrao.com/blog/2020/3/29/coffee-roasting-fundamentals">Scott Rao: Coffee Roasting Fundamentals</a> — probe readings do not transfer directly; time and DTR alone do not establish quality.</p><p>Timing begins with the app’s cultivar worksheet. The 1:15 light-development suggestion floor and illustrated curve are app planning heuristics, not universal roasting rules. Your explicit development override takes precedence.</p><div class="footer">Roast record · Grams & Fahrenheit · Page 2</div></section></div>`;
|
||
}
|
||
export function renderReportHtml(plan, name = "") {
|
||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${e(name || plan.fields?.["0.1"] || "Roast plan")}</title><style>body{margin:0}${REPORT_CSS}</style></head><body>${renderReportBody(plan, name)}</body></html>`;
|
||
}
|