Files
roast_command_center/shared/report.js
T
snowspeeder 5f3a015ea2
Test and deploy / test-and-deploy (push) Successful in 1m43s
Fix report pagination and rebuild saved plan navigation
2026-09-05 15:47:44 -04:00

143 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
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: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) =>
`<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</div></section>
<section class="sheet"><div class="record-intro"><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>Follow a repeatable preheat routine. Record setpoint separately from the charge BT shown on the curve.</p></div>
<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 RoR, color and aroma. Anticipate the machine’s response and avoid abrupt late cuts. Test one change in the next batch.</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>
<div class="record-notes"><div><h2>Cup / brew notes</h2><div class="notes">${e([after.cupNotes, after.cupNotesLine2].filter(Boolean).join("\n"))}</div></div><div><h2>One change for the next batch</h2><div class="notes">${e(after.oneChange)}</div></div></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>Cultivar timings, the 1:15 light-development suggestion floor and the illustrated curve are planning heuristics. Your explicit development override takes precedence.</p><div class="footer">Roast record · Grams & Fahrenheit</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>`;
}